diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c7f8e50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +description: Report a problem with Primer +labels: [bug] +--- + +## Description + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +## Actual behavior + +## Environment + +- OS: +- Node version: +- Primer version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3624ac5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://github.com/pierceboggan/primer/discussions + about: Ask questions and discuss ideas here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..dabc116 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature request +description: Suggest an enhancement for Primer +labels: [enhancement] +--- + +## Summary + +## Problem statement + +## Proposed solution + +## Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..84350e7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## Summary + +- [ ] What changed +- [ ] Why it changed + +## Checklist + +- [ ] Tests added or updated +- [ ] Lint/typecheck pass +- [ ] Docs updated if needed diff --git a/.github/agents/code-review-codex.agent.md b/.github/agents/code-review-codex.agent.md new file mode 100644 index 0000000..ba3a4aa --- /dev/null +++ b/.github/agents/code-review-codex.agent.md @@ -0,0 +1,167 @@ +--- +description: Code review following VS Code contribution standards — correctness, lifecycle, naming, layering, accessibility, and security +name: Code Review (Codex) +tools: ["search", "read/problems", "read/terminalLastCommand", "web/githubRepo"] +model: GPT-5.3-Codex (copilot) +handoffs: + - label: Fix Issues + agent: agent + prompt: Fix the issues identified in the code review above. + send: false +--- + +You are a code reviewer for the VS Code codebase. Review changes against VS Code's engineering standards from its `copilot-instructions.md`, ESLint config, and codebase conventions. + +# Review Process + +1. **Understand context** — Read changed files and surrounding code to understand intent +2. **Check correctness** — Logic, edge cases, error handling, off-by-one errors +3. **Check VS Code conventions** — Naming, disposables, layering, localization, style, accessibility +4. **Check security** — OWASP Top 10 where relevant +5. **Check testing** — Disposable leak checks, coverage of new behavior + +# VS Code Conventions Checklist + +## Indentation + +- Use **tabs**, not spaces + +## Naming + +- **Classes, interfaces, enums, type aliases**: `PascalCase` +- **Interfaces**: prefix with `I` (e.g., `IDisposable`, `IEditorService`) +- **Enum values**: `PascalCase` +- **Functions, methods, properties, local variables**: `camelCase` +- **Private/protected members**: prefix with `_` (e.g., `private _myField`) +- **Service decorators**: `createDecorator('serviceName')` +- Use whole words in names when possible + +## Strings + +- Use `"double quotes"` for user-facing strings that need localization +- Use `'single quotes'` for everything else +- All user-visible strings must use `localize()` or `nls.localize()` +- Never concatenate localized strings — use placeholders (`{0}`, `{1}`) + +## UI Labels + +- Title-style capitalization for command labels, buttons, and menu items +- Don't capitalize prepositions of four or fewer letters unless first or last word + +## Types + +- Don't export types or functions unless shared across multiple components +- Don't introduce new types or values to the global namespace +- Don't use `any` or `unknown` unless absolutely necessary — define proper types + +## Comments + +- Use JSDoc style comments for functions, interfaces, enums, and classes + +## Style + +- Prefer arrow functions `=>` over anonymous function expressions +- Only surround arrow function parameters when necessary (`x => x` not `(x) => x`, but `(x, y) => x + y` is fine) +- Always surround loop and conditional bodies with curly braces +- Open curly braces on the same line as the statement +- Prefer top-level `export function x() {}` over `export const x = () => {}` (better stack traces) +- Prefer `async`/`await` over `.then()` chains +- Prefer named regex capture groups over numbered ones + +## Disposable Lifecycle + +- Classes holding resources must extend `Disposable` and use `this._register()` to track child disposables +- Use `DisposableStore`, `MutableDisposable`, or `DisposableMap` — never raw `IDisposable[]` +- Event listeners, file watchers, and providers must be registered via `this._register()` +- Do NOT register a disposable to the containing class if created in a method called repeatedly — return `IDisposable` from the method and let the caller register it +- Disposables must not be leaked: verify `dispose()` is called or ownership is transferred +- Prefer correlated file watchers (via `fileService.createWatcher`) over shared ones + +## Layering & Architecture + +- `/common/` — no DOM, no Node.js, no Electron imports +- `/browser/` — may use DOM APIs, never Node.js +- `/node/` or `/electron-main/` — may use Node.js APIs +- Never import `browser` from `common`, or `node` from `browser`/`common` +- Contributions use `registerWorkbenchContribution2()` with appropriate `WorkbenchPhase` +- Use `npm run valid-layers-check` to verify layering + +## Error Handling + +- Use `onUnexpectedError()` for errors in async flows that shouldn't crash +- Use typed error classes (e.g., `BugIndicatingError`) for programming errors +- Never swallow errors silently — at minimum log via `ILogService` + +## Events + +- Use `Emitter` for event sources, expose as `Event` via getter +- Register event listeners with `this._register()` to prevent leaks + +## File Headers + +- Every file must start with the Microsoft copyright header (MIT license) + +## Accessibility + +- Interactive elements must have ARIA labels +- Keyboard navigation must work for all new UI +- Screen reader announcements for dynamic state changes via `aria.alert()` +- Prefer `IHoverService` for tooltips over custom implementations + +## Code Quality + +- Never duplicate imports — reuse existing imports +- Don't duplicate code — look for existing utilities before writing new ones +- Don't use another component's storage keys directly — use proper API +- Clean up any temporary files or scripts created during development + +## Testing + +- `ensureNoDisposablesAreLeakedInTestSuite()` must be called in every test suite +- Minimize assertions — prefer one snapshot-style `assert.deepStrictEqual` over many small assertions +- Don't add tests to the wrong suite (e.g., appending to end of file instead of inside the relevant `suite`) +- Match existing test patterns (`describe`/`test` or `suite`/`test`) consistently + +# Severity Levels + +- **Critical**: Security vulnerabilities, disposable leaks in hot paths, layering violations. Must fix. +- **Major**: Bugs, missing error handling, naming violations, missing localization, `any` casts. Must fix. +- **Minor**: Style improvements, missing region markers, non-blocking refactors. Recommended. +- **Nit**: Cosmetic preferences. Optional. + +# Review Rules + +- Never approve code with Critical or Major findings +- Explain _why_ something is a problem, not just _what_ +- Suggest a concrete fix for Critical and Major findings +- Do not flag style preferences as Major issues +- Do not rewrite working code just because you would write it differently +- Limit feedback to actionable items — no praise or filler + +# Security Checklist + +- XSS: user content rendered via `MarkdownString` must set `supportHtml: false` or sanitize +- Trusted Types: use `TrustedTypePolicy` for dynamic script/style injection +- Secrets: no hardcoded credentials, tokens, or API keys in source +- Input validation: untrusted input validated at extension host / IPC boundaries +- Dependencies: no known vulnerable packages introduced + +# Output Format + +```markdown +## Summary + +One-sentence summary of the overall change quality. + +## Findings + +### [Severity] Title + +**File:** `path/to/file.ts:L42` +**Issue:** Description of the problem and why it matters. +**Suggestion:** Concrete fix or approach. + +## Verdict + +APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION +``` diff --git a/.github/agents/code-review-gemini.agent.md b/.github/agents/code-review-gemini.agent.md new file mode 100644 index 0000000..710568b --- /dev/null +++ b/.github/agents/code-review-gemini.agent.md @@ -0,0 +1,167 @@ +--- +description: Code review following VS Code contribution standards — correctness, lifecycle, naming, layering, accessibility, and security +name: Code Review (Gemini) +tools: ["search", "read/problems", "read/terminalLastCommand", "web/githubRepo"] +model: Gemini 3 Pro (Preview) (copilot) +handoffs: + - label: Fix Issues + agent: agent + prompt: Fix the issues identified in the code review above. + send: false +--- + +You are a code reviewer for the VS Code codebase. Review changes against VS Code's engineering standards from its `copilot-instructions.md`, ESLint config, and codebase conventions. + +# Review Process + +1. **Understand context** — Read changed files and surrounding code to understand intent +2. **Check correctness** — Logic, edge cases, error handling, off-by-one errors +3. **Check VS Code conventions** — Naming, disposables, layering, localization, style, accessibility +4. **Check security** — OWASP Top 10 where relevant +5. **Check testing** — Disposable leak checks, coverage of new behavior + +# VS Code Conventions Checklist + +## Indentation + +- Use **tabs**, not spaces + +## Naming + +- **Classes, interfaces, enums, type aliases**: `PascalCase` +- **Interfaces**: prefix with `I` (e.g., `IDisposable`, `IEditorService`) +- **Enum values**: `PascalCase` +- **Functions, methods, properties, local variables**: `camelCase` +- **Private/protected members**: prefix with `_` (e.g., `private _myField`) +- **Service decorators**: `createDecorator('serviceName')` +- Use whole words in names when possible + +## Strings + +- Use `"double quotes"` for user-facing strings that need localization +- Use `'single quotes'` for everything else +- All user-visible strings must use `localize()` or `nls.localize()` +- Never concatenate localized strings — use placeholders (`{0}`, `{1}`) + +## UI Labels + +- Title-style capitalization for command labels, buttons, and menu items +- Don't capitalize prepositions of four or fewer letters unless first or last word + +## Types + +- Don't export types or functions unless shared across multiple components +- Don't introduce new types or values to the global namespace +- Don't use `any` or `unknown` unless absolutely necessary — define proper types + +## Comments + +- Use JSDoc style comments for functions, interfaces, enums, and classes + +## Style + +- Prefer arrow functions `=>` over anonymous function expressions +- Only surround arrow function parameters when necessary (`x => x` not `(x) => x`, but `(x, y) => x + y` is fine) +- Always surround loop and conditional bodies with curly braces +- Open curly braces on the same line as the statement +- Prefer top-level `export function x() {}` over `export const x = () => {}` (better stack traces) +- Prefer `async`/`await` over `.then()` chains +- Prefer named regex capture groups over numbered ones + +## Disposable Lifecycle + +- Classes holding resources must extend `Disposable` and use `this._register()` to track child disposables +- Use `DisposableStore`, `MutableDisposable`, or `DisposableMap` — never raw `IDisposable[]` +- Event listeners, file watchers, and providers must be registered via `this._register()` +- Do NOT register a disposable to the containing class if created in a method called repeatedly — return `IDisposable` from the method and let the caller register it +- Disposables must not be leaked: verify `dispose()` is called or ownership is transferred +- Prefer correlated file watchers (via `fileService.createWatcher`) over shared ones + +## Layering & Architecture + +- `/common/` — no DOM, no Node.js, no Electron imports +- `/browser/` — may use DOM APIs, never Node.js +- `/node/` or `/electron-main/` — may use Node.js APIs +- Never import `browser` from `common`, or `node` from `browser`/`common` +- Contributions use `registerWorkbenchContribution2()` with appropriate `WorkbenchPhase` +- Use `npm run valid-layers-check` to verify layering + +## Error Handling + +- Use `onUnexpectedError()` for errors in async flows that shouldn't crash +- Use typed error classes (e.g., `BugIndicatingError`) for programming errors +- Never swallow errors silently — at minimum log via `ILogService` + +## Events + +- Use `Emitter` for event sources, expose as `Event` via getter +- Register event listeners with `this._register()` to prevent leaks + +## File Headers + +- Every file must start with the Microsoft copyright header (MIT license) + +## Accessibility + +- Interactive elements must have ARIA labels +- Keyboard navigation must work for all new UI +- Screen reader announcements for dynamic state changes via `aria.alert()` +- Prefer `IHoverService` for tooltips over custom implementations + +## Code Quality + +- Never duplicate imports — reuse existing imports +- Don't duplicate code — look for existing utilities before writing new ones +- Don't use another component's storage keys directly — use proper API +- Clean up any temporary files or scripts created during development + +## Testing + +- `ensureNoDisposablesAreLeakedInTestSuite()` must be called in every test suite +- Minimize assertions — prefer one snapshot-style `assert.deepStrictEqual` over many small assertions +- Don't add tests to the wrong suite (e.g., appending to end of file instead of inside the relevant `suite`) +- Match existing test patterns (`describe`/`test` or `suite`/`test`) consistently + +# Severity Levels + +- **Critical**: Security vulnerabilities, disposable leaks in hot paths, layering violations. Must fix. +- **Major**: Bugs, missing error handling, naming violations, missing localization, `any` casts. Must fix. +- **Minor**: Style improvements, missing region markers, non-blocking refactors. Recommended. +- **Nit**: Cosmetic preferences. Optional. + +# Review Rules + +- Never approve code with Critical or Major findings +- Explain _why_ something is a problem, not just _what_ +- Suggest a concrete fix for Critical and Major findings +- Do not flag style preferences as Major issues +- Do not rewrite working code just because you would write it differently +- Limit feedback to actionable items — no praise or filler + +# Security Checklist + +- XSS: user content rendered via `MarkdownString` must set `supportHtml: false` or sanitize +- Trusted Types: use `TrustedTypePolicy` for dynamic script/style injection +- Secrets: no hardcoded credentials, tokens, or API keys in source +- Input validation: untrusted input validated at extension host / IPC boundaries +- Dependencies: no known vulnerable packages introduced + +# Output Format + +```markdown +## Summary + +One-sentence summary of the overall change quality. + +## Findings + +### [Severity] Title + +**File:** `path/to/file.ts:L42` +**Issue:** Description of the problem and why it matters. +**Suggestion:** Concrete fix or approach. + +## Verdict + +APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION +``` diff --git a/.github/agents/code-review-opus.agent.md b/.github/agents/code-review-opus.agent.md new file mode 100644 index 0000000..bfdeb18 --- /dev/null +++ b/.github/agents/code-review-opus.agent.md @@ -0,0 +1,167 @@ +--- +description: Code review following VS Code contribution standards — correctness, lifecycle, naming, layering, accessibility, and security +name: Code Review (Opus) +tools: ["search", "read/problems", "read/terminalLastCommand", "web/githubRepo"] +model: Claude Opus 4.6 (fast mode) (Preview) (copilot) +handoffs: + - label: Fix Issues + agent: agent + prompt: Fix the issues identified in the code review above. + send: false +--- + +You are a code reviewer for the VS Code codebase. Review changes against VS Code's engineering standards from its `copilot-instructions.md`, ESLint config, and codebase conventions. + +# Review Process + +1. **Understand context** — Read changed files and surrounding code to understand intent +2. **Check correctness** — Logic, edge cases, error handling, off-by-one errors +3. **Check VS Code conventions** — Naming, disposables, layering, localization, style, accessibility +4. **Check security** — OWASP Top 10 where relevant +5. **Check testing** — Disposable leak checks, coverage of new behavior + +# VS Code Conventions Checklist + +## Indentation + +- Use **tabs**, not spaces + +## Naming + +- **Classes, interfaces, enums, type aliases**: `PascalCase` +- **Interfaces**: prefix with `I` (e.g., `IDisposable`, `IEditorService`) +- **Enum values**: `PascalCase` +- **Functions, methods, properties, local variables**: `camelCase` +- **Private/protected members**: prefix with `_` (e.g., `private _myField`) +- **Service decorators**: `createDecorator('serviceName')` +- Use whole words in names when possible + +## Strings + +- Use `"double quotes"` for user-facing strings that need localization +- Use `'single quotes'` for everything else +- All user-visible strings must use `localize()` or `nls.localize()` +- Never concatenate localized strings — use placeholders (`{0}`, `{1}`) + +## UI Labels + +- Title-style capitalization for command labels, buttons, and menu items +- Don't capitalize prepositions of four or fewer letters unless first or last word + +## Types + +- Don't export types or functions unless shared across multiple components +- Don't introduce new types or values to the global namespace +- Don't use `any` or `unknown` unless absolutely necessary — define proper types + +## Comments + +- Use JSDoc style comments for functions, interfaces, enums, and classes + +## Style + +- Prefer arrow functions `=>` over anonymous function expressions +- Only surround arrow function parameters when necessary (`x => x` not `(x) => x`, but `(x, y) => x + y` is fine) +- Always surround loop and conditional bodies with curly braces +- Open curly braces on the same line as the statement +- Prefer top-level `export function x() {}` over `export const x = () => {}` (better stack traces) +- Prefer `async`/`await` over `.then()` chains +- Prefer named regex capture groups over numbered ones + +## Disposable Lifecycle + +- Classes holding resources must extend `Disposable` and use `this._register()` to track child disposables +- Use `DisposableStore`, `MutableDisposable`, or `DisposableMap` — never raw `IDisposable[]` +- Event listeners, file watchers, and providers must be registered via `this._register()` +- Do NOT register a disposable to the containing class if created in a method called repeatedly — return `IDisposable` from the method and let the caller register it +- Disposables must not be leaked: verify `dispose()` is called or ownership is transferred +- Prefer correlated file watchers (via `fileService.createWatcher`) over shared ones + +## Layering & Architecture + +- `/common/` — no DOM, no Node.js, no Electron imports +- `/browser/` — may use DOM APIs, never Node.js +- `/node/` or `/electron-main/` — may use Node.js APIs +- Never import `browser` from `common`, or `node` from `browser`/`common` +- Contributions use `registerWorkbenchContribution2()` with appropriate `WorkbenchPhase` +- Use `npm run valid-layers-check` to verify layering + +## Error Handling + +- Use `onUnexpectedError()` for errors in async flows that shouldn't crash +- Use typed error classes (e.g., `BugIndicatingError`) for programming errors +- Never swallow errors silently — at minimum log via `ILogService` + +## Events + +- Use `Emitter` for event sources, expose as `Event` via getter +- Register event listeners with `this._register()` to prevent leaks + +## File Headers + +- Every file must start with the Microsoft copyright header (MIT license) + +## Accessibility + +- Interactive elements must have ARIA labels +- Keyboard navigation must work for all new UI +- Screen reader announcements for dynamic state changes via `aria.alert()` +- Prefer `IHoverService` for tooltips over custom implementations + +## Code Quality + +- Never duplicate imports — reuse existing imports +- Don't duplicate code — look for existing utilities before writing new ones +- Don't use another component's storage keys directly — use proper API +- Clean up any temporary files or scripts created during development + +## Testing + +- `ensureNoDisposablesAreLeakedInTestSuite()` must be called in every test suite +- Minimize assertions — prefer one snapshot-style `assert.deepStrictEqual` over many small assertions +- Don't add tests to the wrong suite (e.g., appending to end of file instead of inside the relevant `suite`) +- Match existing test patterns (`describe`/`test` or `suite`/`test`) consistently + +# Severity Levels + +- **Critical**: Security vulnerabilities, disposable leaks in hot paths, layering violations. Must fix. +- **Major**: Bugs, missing error handling, naming violations, missing localization, `any` casts. Must fix. +- **Minor**: Style improvements, missing region markers, non-blocking refactors. Recommended. +- **Nit**: Cosmetic preferences. Optional. + +# Review Rules + +- Never approve code with Critical or Major findings +- Explain _why_ something is a problem, not just _what_ +- Suggest a concrete fix for Critical and Major findings +- Do not flag style preferences as Major issues +- Do not rewrite working code just because you would write it differently +- Limit feedback to actionable items — no praise or filler + +# Security Checklist + +- XSS: user content rendered via `MarkdownString` must set `supportHtml: false` or sanitize +- Trusted Types: use `TrustedTypePolicy` for dynamic script/style injection +- Secrets: no hardcoded credentials, tokens, or API keys in source +- Input validation: untrusted input validated at extension host / IPC boundaries +- Dependencies: no known vulnerable packages introduced + +# Output Format + +```markdown +## Summary + +One-sentence summary of the overall change quality. + +## Findings + +### [Severity] Title + +**File:** `path/to/file.ts:L42` +**Issue:** Description of the problem and why it matters. +**Suggestion:** Concrete fix or approach. + +## Verdict + +APPROVE | REQUEST_CHANGES | NEEDS_DISCUSSION +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 968515a..7b077bc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,55 +1,78 @@ -# Copilot Instructions for Primer +# Copilot Instructions for This Repository -## Overview +**Primer** is a TypeScript CLI + VS Code extension for priming repositories for AI-assisted development. See `README.md` for full architecture, command reference, and service details. -**Primer** is a TypeScript CLI tool for priming repositories for AI-assisted development and evaluation. +## Development Checklist -- **Tech Stack:** TypeScript (ESM, strict), Node.js, React (Ink for TUI) -- **Entrypoint:** `src/index.ts` (calls `runCli` in `src/cli.ts`) -- **Key Directories:** - - `src/commands/` — CLI subcommands - - `src/services/` — core logic (analyzer, instructions, generator, git, github, evaluator) - - `src/ui/` — Ink/React-based terminal UI - - `.github/` — Copilot instructions - - `.vscode/` — Editor settings +All changes MUST be verified against this checklist before considered complete: + +- [ ] Implementation covers both CLI and VS Code extension (if applicable) +- [ ] Ran lint/typecheck/test/build npm tasks. ALL PASSED +- [ ] Ran Review subagent. NO BLOCKERS reported + +## Build & Test + +```sh +npm run build # tsup → dist/ +npm run typecheck # tsc --noEmit +npm run lint # eslint (flat config) +npm run test # vitest (single run) +npm run test:watch # vitest (watch mode) +``` + +Run without building: `npx tsx src/index.ts [options]` + +VS Code extension: `node esbuild.mjs` from `vscode-extension/`; typecheck with `npx tsc --noEmit` from there. + +## Code Style + +- ESM syntax everywhere (`"type": "module"`). TypeScript strict mode, ES2022 target. +- Windows/macOS/Linux compatible — use `path.join()`, avoid shell-specific syntax. +- Do not add new build/lint/test tools; use existing npm scripts. ## Architecture -- **CLI:** Uses `commander` to wire subcommands to service functions. -- **Analyzer:** Scans repo files to infer languages, frameworks, and package manager. -- **Instructions:** Generates `.github/copilot-instructions.md` from convention files. -- **Config Generation:** Writes `.vscode/settings.json` and `.vscode/mcp.json` (safe overwrite). -- **Git/GitHub:** Automates clone/branch/PR via `simple-git` and Octokit. -- **Evaluation:** Compares agent responses with/without instructions using Copilot sessions. -- **TUI:** `src/ui/tui.tsx` (Ink/React) for interactive terminal usage. - -## Usage - -- **Run locally (no build step):** - - `npx tsx src/index.ts --help` - - `npx tsx src/index.ts analyze [path] --json` - - `npx tsx src/index.ts generate mcp|vscode [path] [--force]` - - `npx tsx src/index.ts instructions [--repo ] [--output ] [--model gpt-5]` - - `npx tsx src/index.ts tui [--repo ]` - - `npx tsx src/index.ts pr [--branch primer/add-configs]` - - `npx tsx src/index.ts eval [configPath] --repo [--model gpt-5] [--judge-model gpt-5] [--output results.json]` +- **Entrypoint:** `src/index.ts` → `runCli()` in `src/cli.ts` +- **Commands** (`src/commands/`) are thin orchestrators — parse options, call services, format output. +- **Services** (`src/services/`) contain all business logic. Commands never access APIs or filesystem directly. +- **UI** (`src/ui/`) — Ink/React 19 components for interactive TUI. Use Ink 6 APIs. +- **Utils** (`src/utils/`) — `output.ts`, `fs.ts`, `logger.ts`, `repo.ts`, `pr.ts`. +- **VS Code Extension** (`vscode-extension/`) — companion extension; imports CLI services via path alias `primer/*`. See extension-specific instructions for details. ## Conventions -- **ESM everywhere** (`"type": "module"` in `package.json`) -- **Strict TypeScript** (`tsconfig.json` targets ES2022, module ESNext, strict mode) -- **Single glob pass** for convention sources (see `src/services/instructions.ts`) -- **Safe file writes:** Overwrites only with `--force` -- **VS Code Copilot settings** reference this file and enable MCP +### Output Discipline -## Prerequisites +- `stdout` is for JSON only (with `--json`); **all human-readable output goes to `stderr`**. +- All commands MUST support `--json` and `--quiet` flags. Use `withGlobalOpts()` from `src/cli.ts` to merge global flags into command options. +- Commands return `CommandResult` with `{ ok, status, data?, errors? }` (from `src/utils/output.ts`). Status values: `"success"`, `"partial"`, `"noop"`, `"error"`. +- Use `outputResult()` / `outputError()` for final output — never `console.log()`. -- **Copilot CLI** must be installed and authenticated for SDK calls. -- **GitHub automation** requires `GITHUB_TOKEN` or `GH_TOKEN` in the environment. +### File Safety -## Key Files +- Use `safeWriteFile()` from `src/utils/fs.ts` for all user-path file writes. It rejects symlinks and skips existing files unless `--force`. +- Use `validateCachePath()` to prevent traversal attacks in `.primer-cache/`. + +### Error Handling + +- Services throw meaningful `Error` messages. Commands catch and pass to `outputError()`. +- Don't re-wrap errors or add extra logging in catch blocks. + +### Dependencies + +- CLI uses Commander, simple-git, Octokit. VS Code extension uses the built-in `vscode.git` API — **never bundle `simple-git` in the extension**. +- Copilot SDK sessions use `workingDirectory` in `SessionConfig` for scoping. + +## Testing + +- **Framework:** Vitest. Tests live in `src/services/__tests__/` with `.test.ts` suffix. +- **Mocking:** Use `vi.fn()` for functions, `vi.spyOn()` for methods. No `vi.mock()` — inline mocks and factory helpers preferred. +- **Filesystem tests:** Use real temp directories (`os.tmpdir()` + `fs.mkdtemp`). Clean up in `afterEach()`. +- **Test naming:** `describe()` per function/export, `it()` names start with a verb and form a sentence. +- **No shared test utils** — helper functions are scoped to each test file's `describe()` block. + +## Prerequisites -- `.github/copilot-instructions.md` — This file -- `src/index.ts` — CLI entrypoint -- `src/services/` — Core logic -- `.vscode/settings.json` — Copilot/VS Code integration \ No newline at end of file +- **Copilot CLI** installed and authenticated for SDK calls +- **GitHub:** `GITHUB_TOKEN` or `GH_TOKEN` env var, or `gh` CLI +- **Azure DevOps:** `AZURE_DEVOPS_PAT` env var diff --git a/.github/instructions/vscode-extension.instructions.md b/.github/instructions/vscode-extension.instructions.md new file mode 100644 index 0000000..4f2461f --- /dev/null +++ b/.github/instructions/vscode-extension.instructions.md @@ -0,0 +1,54 @@ +--- +description: "Use when working on the VS Code extension in vscode-extension/. Covers build, git integration, path aliases, and extension-specific patterns." +applyTo: "vscode-extension/**" +--- + +# VS Code Extension Development + +The extension lives in `vscode-extension/` and surfaces Primer CLI commands in VS Code. + +## Build + +```sh +cd vscode-extension +node esbuild.mjs # CJS bundle → out/extension.js +node esbuild.mjs --watch # watch mode +npx tsc --noEmit # typecheck +``` + +Output is CommonJS (not ESM like the CLI). Bundled with esbuild, not tsup. + +## Service Reuse via Path Alias + +The extension imports CLI services through a `primer/*` path alias: + +```typescript +// vscode-extension/src/services.ts — re-export layer +export { analyzeRepo } from "primer/services/analyzer.js"; +``` + +This works because `tsconfig.json` maps `"primer/*": ["../src/*"]` and esbuild resolves it at bundle time. Never duplicate CLI service logic in the extension. + +## Git Integration + +- Use the built-in `vscode.git` extension API — **never import or bundle `simple-git`**. +- The extension declares `"extensionDependencies": ["vscode.git"]`. +- Types are vendored in `src/git.d.ts` from the upstream VS Code repo. +- `gitUtils.ts` finds the deepest matching repo for monorepo support. + +## Command Pattern + +Commands in `vscode-extension/src/commands/` are thin wrappers: + +1. Call shared service from `services.ts` +2. Update tree providers / status bar +3. Show VS Code notifications for results + +## Key Differences from CLI + +| Aspect | CLI | Extension | +| ------------- | ------------- | -------------- | +| Module format | ESM | CommonJS | +| Bundler | tsup | esbuild | +| Git library | simple-git | vscode.git API | +| Output | stdout/stderr | VS Code UI | diff --git a/.github/prompts/deslop.prompt.md b/.github/prompts/deslop.prompt.md new file mode 100644 index 0000000..d1ec948 --- /dev/null +++ b/.github/prompts/deslop.prompt.md @@ -0,0 +1,14 @@ +--- +name: deslop +description: Remove AI-generated slop from a branch by diffing against main +--- + +Get the diff (`git diff main...HEAD`) and remove AI-generated slop from changed files: + +- Comments inconsistent with the rest of the file +- Defensive checks or try/catch blocks abnormal for that codepath +- `any` casts used to work around type issues +- Inline imports (move to top of file) +- Style inconsistencies with the surrounding code + +Preserve legitimate changes. Report a 1-3 sentence summary of what was removed. diff --git a/.github/prompts/generate-improvements.prompt.md b/.github/prompts/generate-improvements.prompt.md new file mode 100644 index 0000000..d482130 --- /dev/null +++ b/.github/prompts/generate-improvements.prompt.md @@ -0,0 +1,135 @@ +--- +description: Suggest improvements to the Primer CLI project across features, bug fixes, security, performance, and engineering quality. +--- + +You are a senior software engineer reviewing the **Primer** project — a TypeScript CLI tool that primes repositories for AI-assisted development by analyzing codebases, generating Copilot instructions and VS Code configs, running evaluations, and producing AI readiness reports. + +## Architecture Context + +- **Tech Stack:** TypeScript (ESM, strict), Node.js, React (Ink for TUI), Commander for CLI +- **Entrypoint:** `src/index.ts` → `runCli` in `src/cli.ts` +- **Dependencies:** `@github/copilot-sdk`, `@octokit/rest`, `simple-git`, `ink`, `commander`, `fast-glob`, `@inquirer/prompts` + +### Key Directories + +- `src/commands/` — CLI subcommands (`init`, `generate`, `pr`, `eval`, `tui`, `instructions`, `readiness`, `batch`, `batch-readiness`) +- `src/services/` — Core logic: + - `analyzer.ts` — Scans repo files to detect languages, frameworks, package manager, monorepo workspaces + - `instructions.ts` — Generates `.github/copilot-instructions.md` using Copilot SDK agent sessions + - `generator.ts` — Writes `.vscode/settings.json` and `.vscode/mcp.json` configs + - `evaluator.ts` — Runs eval cases comparing agent responses with/without instructions, builds trajectory viewer HTML + - `readiness.ts` — Multi-pillar AI readiness assessment (style, build, testing, docs, dev-env, code-quality, observability, security, ai-tooling) + - `visualReport.ts` — Generates beautiful HTML readiness reports with summary cards, pillar charts, level distribution + - `git.ts` — Clone/branch operations via `simple-git` + - `github.ts` / `azureDevops.ts` — GitHub (Octokit) and Azure DevOps API integrations + - `copilot.ts` — Locates and validates the Copilot CLI binary + - `evalScaffold.ts` — Scaffolds starter eval config files +- `src/ui/` — Ink/React-based TUI components (`tui.tsx`, `BatchTui.tsx`, `BatchReadinessTui.tsx`, `BatchTuiAzure.tsx`, `AnimatedBanner.tsx`) +- `src/utils/` — Shared utilities (`fs.ts` for safe file writes, `logger.ts`, `pr.ts`) + +### CLI Commands + +| Command | Description | +| ------------------------ | ------------------------------------------------------------- | +| `primer init` | Interactive setup wizard (instructions + configs) | +| `primer generate ` | Generate `instructions`, `agents`, `mcp`, or `vscode` configs | +| `primer instructions` | Generate copilot-instructions.md via Copilot SDK | +| `primer eval` | Run evaluation cases comparing with/without instructions | +| `primer readiness` | AI readiness assessment with optional visual HTML report | +| `primer batch` | Batch process multiple repos across GitHub/Azure orgs | +| `primer batch-readiness` | Batch readiness reports across multiple repos | +| `primer pr` | Automate branch/PR creation for generated configs | +| `primer tui` | Interactive Ink-based terminal UI | + +### Key Patterns + +- ESM everywhere (`"type": "module"` in `package.json`) +- Strict TypeScript (ES2022 target, ESNext modules) +- Safe file writes: only overwrites with `--force` flag (`safeWriteFile` in `src/utils/fs.ts`) +- Copilot SDK integration via `@github/copilot-sdk` with session-based agent conversations +- GitHub token resolution: `GITHUB_TOKEN` → `GH_TOKEN` → `gh auth token` fallback chain +- Readiness uses a leveled criteria system (levels 1-5) across 9 pillars with pass/fail/skip status +- Build with `tsup`, test with `vitest`, lint with `eslint`, format with `prettier` + +## Your Task + +Analyze the full codebase and generate a prioritized list of **concrete, actionable improvements**. For each suggestion, provide: + +1. **Title** — short descriptive name +2. **Category** — one of: `feature`, `bug-fix`, `security`, `performance`, `engineering`, `testing`, `dx` (developer experience) +3. **Priority** — `critical`, `high`, `medium`, `low` +4. **Description** — what the problem or opportunity is and why it matters +5. **Suggested implementation** — specific code changes, files to modify, and approach + +## Areas to Evaluate + +### Features & Functionality + +- Are there CLI commands or flags referenced in README/help text that aren't fully implemented? +- Could `analyzeRepo` detect more languages, frameworks, or package managers (e.g., Gradle, Maven, .NET, Ruby)? +- Does `primer init --yes` skip useful defaults (currently only selects instructions, not MCP/VS Code configs)? +- Could `primer readiness` support more output formats (e.g., CSV, PDF) or comparison over time? +- Are there opportunities to improve the batch processing UX (progress, retries, parallel execution)? +- Could `primer eval` scaffold richer default eval cases or support custom grading rubrics? + +### Bug Fixes & Correctness + +- Does `analyzeRepo` correctly handle edge cases like empty repos, non-git directories, or deeply nested monorepos? +- Does `readPnpmWorkspace` handle all valid YAML edge cases or is the line-by-line parser fragile? +- Does the Copilot SDK session handling (`instructions.ts`) properly clean up on errors (session.destroy, client.stop)? +- Are there race conditions in batch processing when cloning/analyzing multiple repos concurrently? +- Does `process.chdir()` in `generateCopilotInstructions` create issues if called concurrently? + +### Security + +- Is the GitHub token (`getGitHubToken`) handled securely — never logged, never leaked in error messages? +- Are user-supplied repo paths validated against path traversal (e.g., `../../etc/passwd` as a repo path)? +- Does `execFileAsync` usage properly sanitize arguments to prevent command injection? +- Are Azure DevOps PAT tokens handled securely throughout the `azureDevops.ts` service? +- Is the `safeWriteFile` function safe against symlink attacks (writing through a symlink to an unintended location)? + +### Performance + +- Could `analyzeRepo` avoid redundant `readdir`/`readFile` calls when the same repo is analyzed multiple times? +- Is `fast-glob` usage in workspace detection efficient for large monorepos with many packages? +- Could the Copilot CLI path lookup (`findCopilotCliPath` in `copilot.ts`) be cached across invocations? +- Are batch operations (batch, batch-readiness) parallelized effectively, or do they process repos sequentially? +- Does the eval trajectory viewer HTML (`evaluator.ts`) generate excessively large output for many eval cases? + +### Engineering Quality + +- Are there TypeScript strict-mode violations, `any` types, or `as` casts that should be eliminated? +- Is error handling consistent across services — do all commands give clear, actionable error messages? +- Are there dead code paths or unused exports in the services or commands? +- Could the `process.chdir()` pattern in `instructions.ts` be replaced with a safer approach (e.g., passing cwd to child processes)? +- Are service interfaces well-separated for testability, or are there tight couplings (e.g., direct `process.env` reads)? + +### Testing + +- What is the current test coverage? Only `analyzer.test.ts`, `fs.test.ts`, `readiness.test.ts`, and `visualReport.test.ts` exist — many services and commands are untested. +- Are there tests for the Copilot SDK integration paths (even with mocked SDK)? +- Are edge cases in `readPnpmWorkspace`, `detectWorkspace`, and `resolveWorkspaceApps` covered? +- Are there integration tests for the full `primer init` or `primer generate` flows? +- Is the GitHub/Azure DevOps API integration tested with mocked HTTP responses? + +### Developer Experience + +- Is the `npx tsx` workflow sufficient, or should there be a `dev` script for faster iteration? +- Are error messages clear when prerequisites are missing (Copilot CLI, GitHub token, `gh` CLI)? +- Is the TUI (`src/ui/tui.tsx`) tested or difficult to test due to Ink rendering? +- Are there missing npm scripts for common workflows (e.g., `npm run dev`, `npm run test:unit`)? + +## Output Format + +Return the improvements as a numbered list grouped by category. Use this structure: + +``` +## Category Name + +### 1. Title (Priority: critical/high/medium/low) +**Problem:** What's wrong or missing +**Suggestion:** Specific changes to make +**Files:** Which files to modify +``` + +Focus on substance over volume. Prefer 10 high-quality, specific suggestions over 30 vague ones. Always reference actual code, file paths, and function names from the codebase. diff --git a/.github/prompts/review.prompt.md b/.github/prompts/review.prompt.md new file mode 100644 index 0000000..6150a18 --- /dev/null +++ b/.github/prompts/review.prompt.md @@ -0,0 +1,11 @@ +--- +name: review +description: Run three parallel code reviews (Opus, Gemini, Codex) and synthesize findings into a prioritized fix list +--- + +Run a multi-model code review: + +1. Invoke `code-review-opus`, `code-review-gemini`, and `code-review-codex` as three parallel subagents +2. Cross-grade: have each reviewer evaluate the other two reviews for false positives and missed issues +3. Synthesize a deduplicated list of findings ordered by severity (Critical > Major > Minor > Nit) +4. Output one final fix list with file, line, and suggested change for each item diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1ca4b0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Lint + run: npm run lint + - name: Format check + run: npm run format:check + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + + typecheck-extension: + name: Typecheck Extension + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + vscode-extension/package-lock.json + - run: npm ci + - run: npm ci + working-directory: vscode-extension + - run: npx tsc --noEmit + working-directory: vscode-extension + + test: + name: Test (Node ${{ matrix.node }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + node: [20, 22] + os: [ubuntu-latest] + include: + - node: 22 + os: windows-latest + - node: 22 + os: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - name: Test + run: npm run test:coverage + - name: Upload coverage + if: matrix.node == 22 && matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage + if-no-files-found: ignore + + build: + name: Build & Verify + runs-on: ubuntu-latest + needs: [lint, typecheck, typecheck-extension, test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + vscode-extension/package-lock.json + - run: npm ci + - run: npm run build + - name: Verify CLI + run: node dist/index.js --version + - name: Build extension + run: npm ci && node esbuild.mjs + working-directory: vscode-extension diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml new file mode 100644 index 0000000..df043b3 --- /dev/null +++ b/.github/workflows/eval.yml @@ -0,0 +1,190 @@ +name: Eval + +on: + workflow_dispatch: + inputs: + model: + description: "Model for responses (default: repo config)" + required: false + judge-model: + description: "Model for judging (default: repo config)" + required: false + fail-threshold: + description: "Fail if pass rate (%) is below this value" + required: false + default: "50" + push: + branches: [main] + paths: + - ".github/copilot-instructions.md" + - ".github/**/*.instructions.md" + - "primer.eval.json" + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - ".github/copilot-instructions.md" + - ".github/**/*.instructions.md" + - "primer.eval.json" + +concurrency: + group: eval-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + eval: + name: Run Evals + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run build + + - name: Install Copilot CLI + run: npm install -g @github/copilot + env: + GH_TOKEN: ${{ secrets.COPILOT_TOKEN }} + + - name: Verify Copilot CLI + run: copilot --version + env: + GH_TOKEN: ${{ secrets.COPILOT_TOKEN }} + + - name: Run evals + id: eval + continue-on-error: true + run: | + mkdir -p .primer/evals + + ARGS="--json --output .primer/evals/results.json" + + if [ -n "${{ inputs.model }}" ]; then + ARGS="$ARGS --model ${{ inputs.model }}" + fi + if [ -n "${{ inputs.judge-model }}" ]; then + ARGS="$ARGS --judge-model ${{ inputs.judge-model }}" + fi + + THRESHOLD="${{ inputs.fail-threshold || '50' }}" + ARGS="$ARGS --fail-level $THRESHOLD" + + node dist/index.js eval $ARGS 2>&1 | tee .primer/evals/eval.log + env: + GH_TOKEN: ${{ secrets.COPILOT_TOKEN }} + + - name: Upload eval results + if: always() + uses: actions/upload-artifact@v4 + with: + name: eval-results + path: .primer/evals/ + if-no-files-found: warn + + - name: Report eval results + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let summary = ''; + let isPR = context.eventName === 'pull_request'; + + try { + const raw = fs.readFileSync('.primer/evals/results.json', 'utf8'); + const data = JSON.parse(raw); + const results = data.results || []; + const total = results.length; + const passed = results.filter(r => r.verdict === 'pass').length; + const failed = results.filter(r => r.verdict === 'fail').length; + const unknown = results.filter(r => r.verdict === 'unknown').length; + const passRate = total > 0 ? Math.round((passed / total) * 100) : 0; + const duration = data.runMetrics?.durationMs + ? `${(data.runMetrics.durationMs / 1000).toFixed(1)}s` + : 'N/A'; + + const icon = passed === total ? '✅' : failed > 0 ? '❌' : '⚠️'; + summary += `## ${icon} Primer Eval: ${passed}/${total} pass (${passRate}%)\n\n`; + summary += `> **${duration}** · model \`${data.model}\` · judge \`${data.judgeModel}\`\n\n`; + + summary += `| Case | Verdict | Score | Rationale |\n`; + summary += `|------|---------|-------|-----------|\n`; + for (const r of results) { + const vIcon = r.verdict === 'pass' ? '✅' : r.verdict === 'fail' ? '❌' : '⚠️'; + const rationale = (r.rationale || '').replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 200); + summary += `| \`${r.id}\` | ${vIcon} ${r.verdict || 'unknown'} | ${r.score ?? '-'} | ${rationale} |\n`; + } + + // Per-case response details in collapsed sections + summary += '\n### Details\n\n'; + for (const r of results) { + const m = r.metrics || {}; + const wi = m.withInstructions || {}; + const wo = m.withoutInstructions || {}; + const fmtMs = ms => ms < 1000 ? `${ms}ms` : `${(ms/1000).toFixed(1)}s`; + const fmtTok = n => n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n || 0); + + summary += `
${r.id} · ${r.verdict === 'pass' ? '✅' : '❌'} ${r.score ?? 0}/100\n\n`; + summary += `**Prompt:** ${r.prompt}\n\n`; + summary += `**Expected:** ${r.expectation}\n\n`; + if (r.rationale) summary += `**Judge:** ${r.rationale}\n\n`; + + summary += `| Metric | Without instructions | With instructions |\n`; + summary += `|--------|---------------------|-------------------|\n`; + summary += `| Time | ${fmtMs(wo.durationMs || 0)} | ${fmtMs(wi.durationMs || 0)} |\n`; + summary += `| Tokens | ${fmtTok(wo.tokenUsage?.totalTokens)} | ${fmtTok(wi.tokenUsage?.totalTokens)} |\n`; + summary += `| Tool calls | ${wo.toolCalls?.count || 0} | ${wi.toolCalls?.count || 0} |\n`; + summary += `\n
\n\n`; + } + } catch (err) { + summary += `## ⚠️ Primer Eval\n\nCould not read eval results: ${err.message}\n`; + } + + // Write to Actions job summary (visible in run UI and PR checks tab) + await core.summary.addRaw(summary).write(); + + // Also post/update as PR comment + if (isPR) { + const marker = ''; + const body = marker + '\n' + summary; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + } + + - name: Fail on threshold + if: steps.eval.outcome == 'failure' + run: | + echo "::error::Eval pass rate fell below threshold" + exit 1 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..713f1ed --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,83 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: Release + id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: release-please-manifest.json + + build-extension-cross-platform: + name: Build Extension (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + vscode-extension/package-lock.json + - run: npm ci --ignore-scripts + - run: npm ci + working-directory: vscode-extension + - name: Typecheck extension + run: npx tsc --noEmit + working-directory: vscode-extension + - name: Build extension + run: node esbuild.mjs --production + working-directory: vscode-extension + + package-extension: + name: Package Extension + runs-on: ubuntu-latest + needs: [release-please, build-extension-cross-platform] + if: ${{ needs.release-please.outputs.release_created == 'true' && needs.build-extension-cross-platform.result == 'success' }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + vscode-extension/package-lock.json + - run: npm ci + - run: npm ci + working-directory: vscode-extension + - name: Build extension + run: node esbuild.mjs --production + working-directory: vscode-extension + - name: Package VSIX + run: npx @vscode/vsce package --no-dependencies + working-directory: vscode-extension + - name: Upload VSIX to release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload --clobber ${{ needs.release-please.outputs.tag_name }} vscode-extension/*.vsix diff --git a/.gitignore b/.gitignore index 6b4a136..2b64acf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules/ # Build output dist/ +out/ +coverage/ # IDE .vscode/ @@ -25,3 +27,8 @@ npm-debug.log* # Primer cache .primer-cache/ + +# Primer outputs +.primer/ +readiness-report.html +*.vsix diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1ad2e3d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": false, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97d493f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [2.0.0] + +### Complete Rewrite + +Primer vNext is a complete rewrite as a TypeScript CLI tool (ESM, strict, ES2022) for priming repositories for AI-assisted development and evaluation. + +### New Commands + +- **`primer readiness`** — AI readiness report scoring repos across 9 pillars (style, build, testing, docs, dev-env, code-quality, observability, security, AI tooling) with a 5-level maturity model (Functional → Autonomous). +- **`primer readiness --visual`** — GitHub-themed HTML report with light/dark toggle, expandable pillar details, and maturity model descriptions. +- **`primer readiness --per-area`** — Per-area readiness scoring for monorepos with area-scoped criteria and aggregate thresholds. +- **`primer readiness --policy`** — Customizable readiness policies (disable/override criteria, tune thresholds) via JSON, JS/TS, or npm packages; chainable with last-wins semantics. +- **`primer batch-readiness`** — Consolidated visual readiness report across multiple repositories, with `--policy` support. +- **`primer generate instructions`** — Generate `copilot-instructions.md` via Copilot SDK, with `--per-app` support for monorepos. +- **`primer generate agents`** — Generate `AGENTS.md` guidance files. +- **`primer instructions --areas`** — Generate file-based `.instructions.md` files scoped to detected areas with `applyTo` glob patterns. +- **`primer eval --init`** — AI-powered eval scaffold generation that analyzes codebases and produces cross-cutting, area-aware eval cases. +- **`primer eval --list-models`** — List available Copilot CLI models. +- **`primer analyze`** — Standalone repo analysis command with structured `--json` output. + +### VS Code Extension + +- 8 command palette commands: Analyze, Generate Configs, Generate Instructions, AI Readiness Report, Run Eval, Scaffold Eval, Initialize Repository, Create PR. +- Sidebar tree views: Analysis (languages, frameworks, monorepo areas) and Readiness (9-pillar scores with color-coded criteria). +- Webview panels for readiness HTML reports and eval results. +- Dynamic status bar showing detected languages after analysis. +- PR creation with default-branch guard, selective file staging, and GitHub auth via VS Code API. +- esbuild-bundled CJS output; CI typecheck and release-time VSIX packaging. + +### New Features + +- **Azure DevOps integration** — Full support for batch processing, PR creation, and repo cloning via Azure DevOps PAT authentication. +- **Headless automation** — Global `--json` and `--quiet` flags on all commands; `CommandResult` envelope with `ok`/`status`/`data`/`errors`. Headless batch mode via positional args or stdin piping. +- **Policy system** — Layered policy chain for readiness reports: disable/override criteria, add extras, tune pass-rate thresholds. Config-sourced policies restricted to JSON-only for security. +- **Per-area readiness** — 4 area-scoped criteria (`area-readme`, `area-build-script`, `area-test-script`, `area-instructions`) with 80% aggregate pass threshold. +- **File-based area instructions** — `.instructions.md` files with YAML frontmatter (`description`, `applyTo`) for VS Code Copilot area scoping. +- **Expanded monorepo detection** — Bazel (`MODULE.bazel`/`WORKSPACE`), Nx (`project.json`), Pants (`pants.toml`), Turborepo overlay, in addition to Cargo, Go, .NET, Gradle, Maven, npm/pnpm/yarn workspaces. +- **Smart area fallback** — Large repos with 10+ top-level dirs automatically discover areas via heuristic scanning with symlink-safe directory traversal. +- **Eval trajectory viewer** — Interactive HTML viewer comparing responses with/without instructions, including token usage, tool call metrics, and duration tracking. +- **Windows Copilot CLI support** — `.cmd`/`.bat` wrapper handling via `cmd /c`, npm-loader.js detection, and `CopilotCliConfig` type replacing bare string paths. +- **Copilot CLI discovery** — Cross-platform discovery with TTL caching and glob-based fallback for VS Code extension paths. +- **Centralized model defaults** — Default model set to `claude-sonnet-4.5` via `src/config.ts`. + +### Improvements + +- All file write paths now use `safeWriteFile()` — instructions, agents, and area files all reject symlinks and respect `--force`. +- Unified `primer pr` command: both GitHub and Azure DevOps generate all three artifacts (instructions + MCP + VS Code configs) with consistent branch naming. +- `CommandResult` output envelope with structured JSON to stdout; human-readable output to stderr. +- `ProgressReporter` interface for silent or human-readable progress across CLI and headless modes. +- Symlink-safe directory scanning via `isScannableDirectory()` with `lstat` + `realpath` containment checks. +- Path traversal protection via `validateCachePath` for cloned repo paths and double-layer defense for area `applyTo` patterns. +- Credential sanitization in git push error messages to prevent token leaks. +- `buildAuthedUrl` utility supporting both GitHub (`x-access-token`) and Azure DevOps (`pat`) auth. +- `checkRepoHasInstructions` now re-throws non-404 errors instead of silently returning false. +- `init --yes` now generates instructions, MCP, and VS Code configs (previously only instructions). +- CSP meta tags added to eval and readiness HTML report generators. + +### Removed + +- Removed stub commands: `templates`, `update`, `config`. +- Removed `src/utils/cwd.ts` — replaced by Copilot SDK `workingDirectory` session config. + +### Testing & Tooling + +- Vitest test framework with 267 tests across 13 test files covering analyzer, generator, git, readiness, visual report, fs utilities, cache path validation, policies, boundaries, CLI, output utilities, and PR helpers. +- ESLint flat config with TypeScript, import ordering, and Prettier integration. +- CI workflow with lint, typecheck, tests (Node 20/22, Ubuntu/macOS/Windows), build verification, and extension typecheck. +- CI dogfooding: runs `primer analyze --json` and `primer readiness --json` on the repo itself. +- Release automation via release-please with VSIX packaging for the VS Code extension. +- Code coverage via `@vitest/coverage-v8`. + +### Project Setup + +- Added CONTRIBUTING.md, SECURITY.md, LICENSE (MIT), and CODEOWNERS. +- Added issue templates (bug report, feature request) and PR template. +- Added `.github/agents/` with multi-model code review agents (Opus, Gemini, Codex). +- Added `.github/prompts/` with reusable prompts (deslop, review, generate-improvements). +- Added examples folder with sample eval config and CLI usage guide. +- Added `.prettierrc.json` with project formatting rules. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..77511c7 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @pierceboggan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e66fc98 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +Thanks for contributing to Primer. + +## Quick start + +1. Fork and clone the repo. +2. Install dependencies: npm install +3. Build locally: npm run build +4. Run lint/typecheck/tests before opening a PR: + - npm run lint + - npm run typecheck + - npm run test + +## Development workflow + +- Create a feature branch from main. +- Use clear, conventional commit messages (e.g. feat: add readiness report). +- Keep PRs focused and include context in the description. +- Add or update tests when behavior changes. + +## Code style + +- ESLint + Prettier are enforced in CI. +- Prefer small, composable functions with clear types. + +## Reporting issues + +- Use GitHub Issues for bugs and feature requests. +- Provide steps to reproduce and expected behavior. + +## Releasing + +Releases are automated with release-please when changes are merged to main. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb6db3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Primer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md index 3dcc268..6d92bfe 100644 --- a/PLAN.md +++ b/PLAN.md @@ -12,21 +12,22 @@ Make any repository "AI-ready" with a single command — generating optimal conf ## ✨ Core Features -### 1. **Repository Analysis** -- Detect language(s), frameworks, and project structure -- Identify existing AI configurations -- Analyze package managers, build tools, and testing frameworks -- Detect monorepo vs single-project structure +### 1. **Readiness Report** + +- Score AI readiness across key pillars +- Provide fix-first checklists and maturity levels +- Support monorepos with app-scoped checks ### 2. **Configuration Generation** -| Config Type | Description | -|-------------|-------------| -| **Custom Instructions** | `.github/copilot-instructions.md` generated via Copilot SDK | -| **MCP Server Config** | `.vscode/mcp.json` for Model Context Protocol servers | -| **VS Code Settings** | `.vscode/settings.json` with AI-optimized workspace settings | +| Config Type | Description | +| ----------------------- | ------------------------------------------------------------ | +| **Custom Instructions** | `.github/copilot-instructions.md` generated via Copilot SDK | +| **MCP Server Config** | `.vscode/mcp.json` for Model Context Protocol servers | +| **VS Code Settings** | `.vscode/settings.json` with AI-optimized workspace settings | ### 3. **GitHub Integration** + - Authenticate via GitHub CLI (`gh auth`) or OAuth device flow - List and select from accessible repositories - Clone repos temporarily for analysis @@ -34,11 +35,13 @@ Make any repository "AI-ready" with a single command — generating optimal conf - Support for GitHub Enterprise ### 4. **Local Repository Support** + - Detect local Git repositories - Work offline with local-only mode - Push changes to remote when ready ### 5. **Interactive & Non-Interactive Modes** + - Beautiful TUI with prompts and previews - CI/CD-friendly `--yes` flag for automation - JSON output for scripting @@ -62,17 +65,17 @@ Make any repository "AI-ready" with a single command — generating optimal conf ### Tech Stack Recommendation -| Component | Choice | Rationale | -|-----------|--------|-----------| -| **Language** | TypeScript | Type safety, excellent tooling, npm ecosystem | -| **CLI Framework** | [Commander.js](https://github.com/tj/commander.js) | Mature, cross-platform, great DX | -| **TUI** | [Ink](https://github.com/vadimdemedes/ink) | React for CLIs, beautiful components | -| **Prompts** | [@inquirer/prompts](https://github.com/SBoudrias/Inquirer.js) | Modern, accessible prompts | -| **GitHub API** | [Octokit](https://github.com/octokit/octokit.js) | Official GitHub SDK | -| **Git Operations** | [simple-git](https://github.com/steveukx/git-js) | Cross-platform Git commands | -| **Styling** | [chalk](https://github.com/chalk/chalk) + [boxen](https://github.com/sindresorhus/boxen) | Beautiful terminal output | -| **Bundling** | [tsup](https://github.com/egoist/tsup) | Fast, zero-config bundler | -| **Distribution** | npm + standalone binaries via [pkg](https://github.com/vercel/pkg) | Maximum reach | +| Component | Choice | Rationale | +| ------------------ | ---------------------------------------------------------------------------------------- | --------------------------------------------- | +| **Language** | TypeScript | Type safety, excellent tooling, npm ecosystem | +| **CLI Framework** | [Commander.js](https://github.com/tj/commander.js) | Mature, cross-platform, great DX | +| **TUI** | [Ink](https://github.com/vadimdemedes/ink) | React for CLIs, beautiful components | +| **Prompts** | [@inquirer/prompts](https://github.com/SBoudrias/Inquirer.js) | Modern, accessible prompts | +| **GitHub API** | [Octokit](https://github.com/octokit/octokit.js) | Official GitHub SDK | +| **Git Operations** | [simple-git](https://github.com/steveukx/git-js) | Cross-platform Git commands | +| **Styling** | [chalk](https://github.com/chalk/chalk) + [boxen](https://github.com/sindresorhus/boxen) | Beautiful terminal output | +| **Bundling** | [tsup](https://github.com/egoist/tsup) | Fast, zero-config bundler | +| **Distribution** | npm + standalone binaries via [pkg](https://github.com/vercel/pkg) | Maximum reach | --- @@ -93,20 +96,30 @@ primer init --github owner/repo primer generate mcp primer generate vscode +# Generate instructions +primer instructions + # Create PR with all generated configs primer pr owner/repo -# Analyze repo without making changes -primer analyze +# Readiness report +primer readiness +primer readiness --fail-level 3 # CI gate -# Update existing configurations -primer update +# Run evaluations +primer eval primer.eval.json +primer eval --init # Scaffold config +primer eval --fail-level 80 # CI gate (pass rate %) -# List available templates -primer templates +# Analyze repository +primer analyze + +# Run TUI +primer tui -# Configure CLI settings -primer config +# Batch processing +primer batch +primer batch-readiness ``` --- @@ -114,6 +127,7 @@ primer config ## 🔍 Repository Detection Logic ### Language Detection Priority + 1. Check for lock files (`package-lock.json`, `yarn.lock`, `Cargo.lock`, `go.sum`, etc.) 2. Analyze file extensions distribution 3. Check for framework-specific files @@ -121,17 +135,18 @@ primer config ### Framework Detection -| Language | Frameworks to Detect | -|----------|---------------------| +| Language | Frameworks to Detect | +| ------------------------- | --------------------------------------------------------------------- | | **JavaScript/TypeScript** | React, Vue, Angular, Next.js, Nuxt, Svelte, Express, Nest.js, Fastify | -| **Python** | Django, Flask, FastAPI, Pandas/NumPy (data science) | -| **Go** | Gin, Echo, Fiber | -| **Rust** | Actix, Axum, Rocket | -| **Java** | Spring Boot, Quarkus | -| **C#** | ASP.NET Core, Blazor | -| **Ruby** | Rails, Sinatra | +| **Python** | Django, Flask, FastAPI, Pandas/NumPy (data science) | +| **Go** | Gin, Echo, Fiber | +| **Rust** | Actix, Axum, Rocket | +| **Java** | Spring Boot, Quarkus | +| **C#** | ASP.NET Core, Blazor | +| **Ruby** | Rails, Sinatra | ### Project Type Classification + - **Frontend**: UI components, styling, client-side routing - **Backend**: API routes, database schemas, authentication - **Full-stack**: Both frontend and backend @@ -150,28 +165,33 @@ primer config # Project: {name} ## Tech Stack + - Language: TypeScript - Framework: Next.js 14 (App Router) - Styling: Tailwind CSS - Database: Prisma + PostgreSQL ## Coding Conventions + - Use functional components with hooks - Prefer server components where possible - Use `cn()` utility for conditional classes - Follow existing patterns in `src/components/` ## File Structure + - `src/app/` - App router pages and layouts - `src/components/` - Reusable UI components - `src/lib/` - Utility functions and shared logic - `src/server/` - Server-side code and API logic ## Testing + - Run tests: `npm test` - Test files: `*.test.ts` colocated with source ## Important Notes + - This project uses {specific conventions} - Avoid {anti-patterns specific to this codebase} ``` @@ -299,6 +319,7 @@ primer config ### GitHub Auth Flow 1. **Check for existing `gh` CLI auth** + ```bash gh auth status ``` @@ -313,6 +334,7 @@ primer config - Fallback to encrypted file in `~/.config/primer/` ### Required Scopes + - `repo` - Full repository access - `read:user` - Read user profile @@ -344,11 +366,11 @@ This PR adds configurations to prime this repository for AI coding assistants. ### Added Files -| File | Purpose | -|------|---------| -| `.github/copilot-instructions.md` | Project context for GitHub Copilot | -| `.vscode/settings.json` | VS Code settings for optimal AI assistance | -| `.vscode/mcp.json` | Model Context Protocol server configuration | +| File | Purpose | +| --------------------------------- | ------------------------------------------- | +| `.github/copilot-instructions.md` | Project context for GitHub Copilot | +| `.vscode/settings.json` | VS Code settings for optimal AI assistance | +| `.vscode/mcp.json` | Model Context Protocol server configuration | ### How to Use @@ -357,7 +379,8 @@ This PR adds configurations to prime this repository for AI coding assistants. 3. Start chatting with Copilot — it now understands your project! --- -*Generated by [Primer](https://github.com/your-org/primer)* + +_Generated by [Primer](https://github.com/your-org/primer)_ ``` --- @@ -369,25 +392,52 @@ primer/ ├── src/ │ ├── index.ts # Entry point │ ├── cli.ts # Commander setup +│ ├── config.ts # Shared constants (DEFAULT_MODEL, etc.) │ ├── commands/ -│ │ ├── init.ts -│ │ ├── generate.ts │ │ ├── analyze.ts +│ │ ├── batch.tsx +│ │ ├── batchReadiness.tsx +│ │ ├── eval.ts +│ │ ├── generate.ts +│ │ ├── init.ts +│ │ ├── instructions.ts │ │ ├── pr.ts -│ │ └── config.ts +│ │ ├── readiness.ts +│ │ └── tui.tsx │ ├── services/ -│ │ ├── github.ts # GitHub API interactions │ │ ├── analyzer.ts # Repo analysis logic +│ │ ├── azureDevops.ts # Azure DevOps integration +│ │ ├── batch.ts # Batch processing logic +│ │ ├── copilot.ts # Copilot CLI integration +│ │ ├── evalScaffold.ts # Eval config scaffolding +│ │ ├── evaluator.ts # Eval runner │ │ ├── generator.ts # Config generation -│ │ └── git.ts # Local git operations +│ │ ├── git.ts # Local git operations +│ │ ├── github.ts # GitHub API interactions +│ │ ├── instructions.ts # Copilot SDK integration +│ │ ├── policy.ts # Policy-driven readiness +│ │ ├── readiness.ts # Readiness assessment +│ │ └── visualReport.ts # HTML report generation │ ├── ui/ -│ │ ├── prompts.ts # Inquirer prompts -│ │ ├── spinner.ts # Loading indicators -│ │ └── preview.ts # File previews +│ │ ├── AnimatedBanner.tsx +│ │ ├── BatchReadinessTui.tsx +│ │ ├── BatchTui.tsx +│ │ ├── BatchTuiAzure.tsx +│ │ └── tui.tsx │ └── utils/ -│ ├── fs.ts # File system helpers -│ ├── detection.ts # Language/framework detection -│ └── logger.ts # Styled console output +│ ├── fs.ts # File system helpers (safeWriteFile) +│ ├── logger.ts # Styled console output +│ ├── output.ts # CommandResult, ProgressReporter +│ ├── pr.ts # PR body templates +│ └── repo.ts # Repo URL parsing +├── vscode-extension/ # VS Code extension +│ ├── src/ +│ │ ├── extension.ts +│ │ ├── services.ts # Re-exports CLI services +│ │ ├── types.ts +│ │ ├── commands/ +│ │ └── views/ +│ └── package.json ├── package.json ├── tsconfig.json └── README.md @@ -398,21 +448,26 @@ primer/ ## 🧪 Testing Strategy ### Unit Tests + - Template rendering with different inputs - Language/framework detection - Config merging logic ### Integration Tests + - Full init flow (mocked filesystem) - GitHub API interactions (mocked) - PR creation flow ### E2E Tests + - Real repo analysis (test fixtures) - Actual file generation ### Test Fixtures + Create example repos for each major stack: + - `fixtures/nextjs-app/` - `fixtures/python-fastapi/` - `fixtures/rust-cli/` @@ -423,12 +478,14 @@ Create example repos for each major stack: ## 🌟 Additional Feature Ideas ### Phase 2 + - [ ] **Team Sync** — Share configs across org/team repos - [ ] **Config Validation** — Lint generated configs - [ ] **Diff View** — Show what will change in existing files - [ ] **Rollback** — Undo generated changes ### Phase 3 + - [ ] **AI Enhancement** — Use AI to generate better project-specific instructions - [ ] **Telemetry** — Anonymous usage stats (opt-in) - [ ] **VS Code Extension** — GUI version of the CLI @@ -436,6 +493,7 @@ Create example repos for each major stack: - [ ] **Monorepo Support** — Generate configs per package ### Community Features + - [ ] **Repo Showcase** — Examples of well-configured repos --- @@ -443,30 +501,34 @@ Create example repos for each major stack: ## 📅 Implementation Phases ### Phase 1: MVP (2-3 weeks) + - [x] Project setup (TypeScript, Commander, tsup) -- [ ] Basic CLI with `init` and `generate` commands -- [ ] Local repo analysis -- [ ] Custom instructions generation via Copilot SDK -- [ ] Generate VS Code settings and MCP configuration -- [ ] Basic interactive prompts +- [x] Basic CLI with `init` and `generate` commands +- [x] Local repo analysis +- [x] Custom instructions generation via Copilot SDK +- [x] Generate VS Code settings and MCP configuration +- [x] Basic interactive prompts ### Phase 2: GitHub Integration (1-2 weeks) -- [ ] GitHub authentication -- [ ] Remote repo access -- [ ] PR creation -- [ ] Fork workflow + +- [x] GitHub authentication +- [x] Remote repo access +- [x] PR creation +- [x] Fork workflow ### Phase 3: Polish (1 week) -- [ ] Beautiful TUI with previews -- [ ] More language/framework support -- [ ] MCP configurations -- [ ] Documentation and examples + +- [x] Beautiful TUI with previews +- [x] More language/framework support +- [x] MCP configurations +- [x] Documentation and examples ### Phase 4: Distribution (1 week) -- [ ] npm publish + +- [x] npm publish - [ ] Standalone binaries - [ ] Homebrew formula -- [ ] CI/CD setup +- [x] CI/CD setup --- @@ -476,24 +538,24 @@ Create example repos for each major stack: # Install dependencies npm install -# Development with watch mode -npm run dev - # Build for production npm run build -# Run tests -npm test - # Lint and format npm run lint npm run format +# Type check +npm run typecheck + +# Run tests +npm run test + +# Coverage +npm run test:coverage + # Link globally for testing npm link - -# Create standalone binaries -npm run package ``` --- @@ -516,4 +578,4 @@ npm run package --- -*This plan is a living document. Update as the project evolves.* +_This plan is a living document. Update as the project evolves._ diff --git a/README.md b/README.md index 7c5dd6c..6e22c27 100644 --- a/README.md +++ b/README.md @@ -2,290 +2,229 @@ > Prime your repositories for AI-assisted development. -Primer is a CLI tool that analyzes your codebase and generates `.github/copilot-instructions.md` files to help AI coding assistants understand your project better. It supports single repos, batch processing across organizations, and includes an evaluation framework to measure instruction effectiveness. +[![CI](https://github.com/pierceboggan/primer/actions/workflows/ci.yml/badge.svg)](https://github.com/pierceboggan/primer/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -![Primer](primer.png) +Primer is a CLI and VS Code extension that helps teams prepare repositories for AI-assisted development. It generates custom instructions, assesses AI readiness across a maturity model, and supports batch processing across organizations. -## Features +## Quick Start -- **Repository Analysis** - Detects languages, frameworks, and package managers -- **AI-Powered Generation** - Uses the Copilot SDK to analyze your codebase and generate context-aware instructions -- **Batch Processing** - Process multiple repos across organizations with a single command -- **Evaluation Framework** - Test and measure how well your instructions improve AI responses -- **GitHub Integration** - Clone repos, create branches, and open PRs automatically -- **Interactive TUI** - Beautiful terminal interface built with Ink -- **Config Generation** - Generate MCP and VS Code configurations - -## Prerequisites +```bash +# Run directly (no install needed) +npx github:pierceboggan/primer readiness +``` -1. **Node.js 18+** -2. **GitHub Copilot CLI** - Installed via VS Code's Copilot Chat extension -3. **Copilot CLI Authentication** - Run `copilot` then `/login` to authenticate -4. **GitHub CLI (optional)** - For batch processing and PR creation: `brew install gh && gh auth login` +`npx github:/primer ...` installs from the Git repository and runs the package `prepare` script, which builds the CLI before first use. -## Installation +Or install locally: ```bash -# Clone and install git clone https://github.com/pierceboggan/primer.git -cd primer -npm install -``` +cd primer && npm install && npm run build && npm link -## Usage +# 1. Check how AI-ready your repo is +primer readiness -### Quick Start (Init) +# 2. Generate AI instructions +primer instructions -The easiest way to get started is with the `init` command: +# 3. Generate MCP and VS Code configs +primer generate mcp +primer generate vscode -```bash -# Interactive setup for current directory -npx tsx src/index.ts init - -# Accept defaults and generate instructions automatically -npx tsx src/index.ts init --yes - -# Work with a GitHub repository -npx tsx src/index.ts init --github +# Or do everything interactively +primer init ``` -### Interactive Mode (TUI) - -```bash -# Run TUI in current directory -npx tsx src/index.ts tui +## Prerequisites -# Run on a specific repo -npx tsx src/index.ts tui --repo /path/to/repo +| Requirement | Notes | +| --------------------------------- | ---------------------------------------------------------------- | +| **Node.js 20+** | Runtime | +| **GitHub Copilot CLI** | Bundled with the VS Code Copilot Chat extension | +| **Copilot authentication** | Run `copilot` → `/login` | +| **GitHub CLI** _(optional)_ | For batch processing and PRs: `brew install gh && gh auth login` | +| **Azure DevOps PAT** _(optional)_ | Set `AZURE_DEVOPS_PAT` for Azure DevOps workflows | -# Skip the animated intro -npx tsx src/index.ts tui --no-animation -``` +## Commands -**Keys:** -- `[A]` Analyze - Detect languages, frameworks, and package manager -- `[G]` Generate - Generate copilot-instructions.md using Copilot SDK -- `[S]` Save - Save generated instructions (in preview mode) -- `[D]` Discard - Discard generated instructions (in preview mode) -- `[Q]` Quit +### `primer analyze` — Inspect Repository Structure -### Generate Instructions +Detects languages, frameworks, monorepo/workspace structure, and area mappings: ```bash -# Generate instructions for current directory -npx tsx src/index.ts instructions - -# Generate for specific repo with custom output -npx tsx src/index.ts instructions --repo /path/to/repo --output ./instructions.md - -# Use a specific model -npx tsx src/index.ts instructions --model gpt-5 +primer analyze # terminal summary +primer analyze --json # machine-readable analysis +primer analyze --output analysis.json # save JSON report +primer analyze --output analysis.md # save Markdown report +primer analyze --output analysis.json --force # overwrite existing report ``` -### Batch Processing +### `primer readiness` — Assess AI Readiness -Process multiple repositories across organizations: +Score a repo across 9 pillars grouped into **Repo Health** and **AI Setup**: ```bash -# Launch batch TUI -npx tsx src/index.ts batch - -# Save results to file -npx tsx src/index.ts batch --output results.json +primer readiness # terminal summary +primer readiness --visual # GitHub-themed HTML report +primer readiness --per-area # include per-area breakdown +primer readiness --output readiness.json # save JSON report +primer readiness --output readiness.md # save Markdown report +primer readiness --output readiness.html # save HTML report +primer readiness --policy ./examples/policies/strict.json # apply a custom policy +primer readiness --json # machine-readable JSON +primer readiness --fail-level 3 # CI gate: exit 1 if below level 3 ``` -**Batch TUI Keys:** -- `[Space]` Toggle selection -- `[A]` Select all repos -- `[Enter]` Confirm selection -- `[Y/N]` Confirm/cancel processing -- `[Q]` Quit +**Maturity levels:** -### Analyze Repository +| Level | Name | What it means | +| ----- | ------------ | --------------------------------------------------- | +| 1 | Functional | Builds, tests, basic tooling in place | +| 2 | Documented | README, CONTRIBUTING, custom AI instructions exist | +| 3 | Standardized | CI/CD, security policies, CODEOWNERS, observability | +| 4 | Optimized | MCP servers, custom agents, AI skills configured | +| 5 | Autonomous | Full AI-native development with minimal oversight | -```bash -# Analyze current directory -npx tsx src/index.ts analyze +### `primer instructions` — Generate Instructions -# Analyze specific path with JSON output -npx tsx src/index.ts analyze /path/to/repo --json -``` - -### Generate Configs - -Generate configuration files for your repo: +Generate `copilot-instructions.md` or `AGENTS.md` using the Copilot SDK: ```bash -# Generate MCP config -npx tsx src/index.ts generate mcp - -# Generate VS Code settings -npx tsx src/index.ts generate vscode --force +primer instructions # copilot-instructions.md (default) +primer instructions --format agents-md # AGENTS.md +primer instructions --per-app # per-app in monorepos +primer instructions --areas # root + all detected areas +primer instructions --area frontend # single area +primer instructions --model claude-sonnet-4.5 +``` -# Generate custom prompts -npx tsx src/index.ts generate prompts +### `primer eval` — Evaluate Instructions -# Generate agent configs -npx tsx src/index.ts generate agents +Measure how instructions improve AI responses with a judge model: -# Generate .aiignore file -npx tsx src/index.ts generate aiignore +```bash +primer eval --init # scaffold eval config from codebase +primer eval primer.eval.json # run evaluation +primer eval --model gpt-4.1 --judge-model claude-sonnet-4.5 +primer eval --fail-level 80 # CI gate: exit 1 if pass rate < 80% ``` -### Manage Templates - -View available instruction templates: +### `primer generate` — Generate Configs ```bash -npx tsx src/index.ts templates +primer generate mcp # .vscode/mcp.json +primer generate vscode --force # .vscode/settings.json (overwrite) ``` -### Configuration - -View and manage Primer configuration: +### `primer batch` / `primer pr` — Batch & PRs ```bash -npx tsx src/index.ts config +primer batch # interactive TUI (GitHub) +primer batch --provider azure # Azure DevOps +primer batch owner/repo1 owner/repo2 --json +primer batch-readiness --output team.html +primer pr owner/repo-name # clone → generate → open PR ``` -### Update - -Check for and apply updates: +### `primer tui` — Interactive Mode ```bash -npx tsx src/index.ts update +primer tui ``` -### Create Pull Requests +### `primer init` — Guided Setup -Automatically create a PR to add Primer configs to a repository: +Interactive or headless repo onboarding — detects your stack and walks through readiness, instructions, and config generation. -```bash -# Create PR for a GitHub repo -npx tsx src/index.ts pr owner/repo-name +### Global Options + +All commands support `--json` (structured JSON to stdout) and `--quiet` (suppress stderr). JSON output uses a `CommandResult` envelope: -# Use custom branch name -npx tsx src/index.ts pr owner/repo-name --branch primer/custom-branch +```json +{ "ok": true, "status": "success", "data": { ... } } ``` -### Evaluation Framework +### Readiness Policies -Test how well your instructions improve AI responses: +Policies customize scoring criteria, override metadata, and tune thresholds: ```bash -# Create a starter eval config -npx tsx src/index.ts eval --init - -# Run evaluation -npx tsx src/index.ts eval primer.eval.json --repo /path/to/repo - -# Save results and use specific models -npx tsx src/index.ts eval --output results.json --model gpt-5 --judge-model gpt-5 +primer readiness --policy ./examples/policies/strict.json +primer readiness --policy ./examples/policies/strict.json,./my-overrides.json # chain multiple ``` -Example `primer.eval.json`: ```json { - "instructionFile": ".github/copilot-instructions.md", - "cases": [ - { - "id": "project-overview", - "prompt": "Summarize what this project does and list the main entry points.", - "expectation": "Should mention the primary purpose and key files/directories." - } - ] + "name": "my-org-policy", + "criteria": { + "disable": ["lint-config"], + "override": { "readme": { "impact": "high", "level": 2 } } + }, + "extras": { "disable": ["pre-commit"] }, + "thresholds": { "passRate": 0.9 } } ``` -## How It Works +Policies can also be set in `primer.config.json` (`{ "policies": ["./my-policy.json"] }`). -1. **Analysis** - Scans the repository for: - - Language files (`.ts`, `.js`, `.py`, `.go`, etc.) - - Framework indicators (`package.json`, `tsconfig.json`, etc.) - - Package manager lock files +> **Security:** Config-sourced policies are restricted to JSON files only — JS/TS module policies must be passed via `--policy`. -2. **Generation** - Uses the Copilot SDK to: - - Start a Copilot CLI session - - Let the AI agent explore your codebase using tools (`glob`, `view`, `grep`) - - Generate concise, project-specific instructions +See [docs/plugins.md](docs/plugins.md) for the full plugin authoring guide, including imperative TypeScript plugins, lifecycle hooks, and the trust model. -3. **Batch Processing** - For multiple repos: - - Select organizations and repositories via TUI - - Clone, branch, generate, commit, push, and create PRs - - Track success/failure for each repository +## Development -4. **Evaluation** - Measure instruction quality: - - Run prompts with and without instructions - - Use a judge model to score responses - - Generate comparison reports +```bash +npm run typecheck # type check +npm run lint # ESLint (flat config + Prettier) +npm run test # Vitest tests +npm run test:coverage # with coverage +npm run build # production build via tsup +npx tsx src/index.ts --help # run from source +``` -## Project Structure +### VS Code Extension -``` -primer/ -├── src/ -│ ├── index.ts # Entry point -│ ├── cli.ts # Commander CLI setup -│ ├── commands/ # CLI commands -│ │ ├── analyze.ts # Repository analysis -│ │ ├── batch.tsx # Batch processing -│ │ ├── config.ts # Config management -│ │ ├── eval.ts # Evaluation framework -│ │ ├── generate.ts # Config generation -│ │ ├── init.ts # Interactive setup -│ │ ├── instructions.tsx # Instructions generation -│ │ ├── pr.ts # PR creation -│ │ ├── templates.ts # Template management -│ │ ├── tui.tsx # TUI launcher -│ │ └── update.ts # Update command -│ ├── services/ # Core business logic -│ │ ├── analyzer.ts # Repository analysis -│ │ ├── evaluator.ts # Eval runner -│ │ ├── generator.ts # Config generation -│ │ ├── git.ts # Git operations -│ │ ├── github.ts # GitHub API -│ │ └── instructions.ts # Copilot SDK integration -│ ├── ui/ # Terminal UI -│ │ ├── AnimatedBanner.tsx -│ │ ├── BatchTui.tsx # Batch processing UI -│ │ └── tui.tsx # Main TUI -│ └── utils/ # Helpers -│ ├── fs.ts -│ └── logger.ts -├── package.json -├── tsconfig.json -├── primer.eval.json # Example eval config -└── PLAN.md # Project roadmap +```bash +cd vscode-extension +npm install && npm run build +# Press F5 to launch Extension Development Host ``` -## Development +See [CONTRIBUTING.md](CONTRIBUTING.md) for workflow and code style guidelines. -```bash -# Type check -npx tsc -p tsconfig.json --noEmit +## Project Structure -# Run in dev mode -npx tsx src/index.ts ``` +src/ +├── cli.ts # Commander CLI wiring +├── commands/ # CLI subcommands (thin orchestrators) +├── services/ # Core logic +│ ├── readiness.ts # 9-pillar scoring engine with pillar groups +│ ├── visualReport.ts # HTML report generator +│ ├── instructions.ts # Copilot SDK integration +│ ├── analyzer.ts # Repo scanning (languages, frameworks, monorepos) +│ ├── evaluator.ts # Eval runner + trajectory viewer +│ ├── generator.ts # MCP/VS Code config generation +│ ├── policy.ts # Readiness policy loading and chain resolution +│ ├── policy/ # Plugin engine (types, compiler, loader, adapter, shadow) +│ ├── git.ts # Git operations (clone, branch, push) +│ ├── github.ts # GitHub API (Octokit) +│ └── azureDevops.ts # Azure DevOps API +├── ui/ # Ink/React terminal UI +└── utils/ # Shared utilities (fs, logger, output) -## Troubleshooting - -### "Copilot CLI not found" -Install the GitHub Copilot Chat extension in VS Code. The CLI is bundled with it. +vscode-extension/ # VS Code extension (commands, tree views, webview) +``` -### "Copilot CLI not logged in" -Run `copilot` in your terminal, then type `/login` to authenticate. +## Troubleshooting -### "GitHub authentication required" (batch/PR commands) -Install GitHub CLI and authenticate: `brew install gh && gh auth login` +**"Copilot CLI not found"** — Install the GitHub Copilot Chat extension in VS Code. The CLI is bundled with it. -Or set a token: `export GITHUB_TOKEN=` +**"Copilot CLI not logged in"** — Run `copilot` in your terminal, then `/login` to authenticate. -### Generation hangs or times out -- Ensure you're authenticated with the Copilot CLI -- Check your network connection -- Try a smaller repository first +**"GitHub authentication required"** — Install GitHub CLI (`brew install gh && gh auth login`) or set `GITHUB_TOKEN` / `GH_TOKEN`. ## License -MIT +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0f09971 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security issues privately by emailing the maintainer or opening a GitHub security advisory. Avoid filing public issues for vulnerabilities. + +We aim to respond within 72 hours and will work with you on remediation and disclosure. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..9b9980d --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,202 @@ +# Primer Plugin System + +The unified plugin policy system allows both imperative (code) plugins and declarative (JSON) policies to run through the same engine. + +## Architecture + +``` +PolicyConfig (JSON) ──┐ + ├─→ compilePolicyConfig() ──→ PolicyPlugin +Module policy (.ts) ──┘ + +Built-in criteria ──→ buildBuiltinPlugin() ──→ PolicyPlugin + +PolicyPlugin[] ──→ executePlugins() ──→ EngineReport + └─→ engineReportToReadiness() ──→ ReadinessReport +``` + +### Pipeline Stages + +The engine executes plugins through 5 deterministic stages: + +1. **Detect** — All detectors emit signals about repository state +2. **afterDetect** — Hooks can modify/add/remove signals via patches +3. **beforeRecommend** — Last chance to adjust signals before recommendations +4. **Recommend** — Recommenders emit actionable recommendations from signals +5. **afterRecommend** — Hooks can modify/add/remove recommendations via patches + +After hooks complete, the engine resolves `supersedes` conflicts and computes a score. + +## Plugin Contract + +```typescript +type PolicyPlugin = { + meta: PluginMeta; + detectors?: Detector[]; + afterDetect?: (signals, ctx) => Promise; + beforeRecommend?: (signals, ctx) => Promise; + recommenders?: Recommender[]; + afterRecommend?: (recs, signals, ctx) => Promise; + onError?: (error, stage, ctx) => boolean; +}; +``` + +### Trust Model + +| Trust Tier | Source | Capabilities | +| ------------------ | ------------------------------ | ---------------------------------------------- | +| `trusted-code` | `.ts`/`.js` module or built-in | Full lifecycle hooks, arbitrary code | +| `safe-declarative` | `.json` policy file | Disable, override metadata, static checks only | + +### Immutable Patches + +All hook stages return patch objects instead of mutating arrays directly: + +```typescript +type SignalPatch = { + add?: Signal[]; + remove?: string[]; // IDs to remove + modify?: Array<{ id: string; changes: Partial }>; +}; +``` + +The engine applies patches and automatically records provenance (`origin.modifiedBy`). + +### Conflict Resolution + +Use `supersedes` on recommendations for explicit conflict resolution: + +```typescript +const rec: Recommendation = { + id: "strict-lint-fix", + signalId: "lint-config", + impact: "high", + message: "Use stricter lint rules", + supersedes: ["basic-lint-fix"], // Replaces this recommendation + origin: { addedBy: "strict-policy" } +}; +``` + +Circular supersedes chains drop all involved recommendations. + +## Writing a Plugin + +There are two authoring APIs: + +- **`PolicyConfig`** — The high-level authoring API. You write a config object with `criteria`/`extras`/`thresholds` and the engine compiles it into a `PolicyPlugin` under the hood. This is the recommended approach for most use cases. +- **`PolicyPlugin`** — The low-level hook-based API (shown in the Plugin Contract section above). Use this only when you need direct control over the 5-stage pipeline hooks. + +### Imperative Plugin (TypeScript via PolicyConfig) + +```typescript +// my-policy.ts +import type { PolicyConfig } from "primer/services/policy"; + +const policy: PolicyConfig = { + name: "my-custom-policy", + criteria: { + disable: ["env-example"], // Skip this check + override: { + "lint-config": { title: "Custom Lint Title", impact: "medium" } + }, + add: [ + { + id: "custom-check", + title: "My Custom Check", + pillar: "code-quality", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + check: async (ctx) => { + // Your check logic here + return { status: "pass", reason: "All good" }; + } + } + ] + } +}; + +export default policy; +``` + +### Declarative Policy (JSON) + +```json +{ + "name": "strict-policy", + "criteria": { + "disable": ["env-example"], + "override": { + "lint-config": { "impact": "high" } + } + }, + "thresholds": { + "passRate": 0.9 + } +} +``` + +## Using Policies + +### CLI + +```bash +# Single policy +primer readiness --policy ./my-policy.json + +# Multiple policies (comma-separated) +primer readiness --policy ./base.json,./strict.json + +# npm package policy +primer readiness --policy @org/primer-policy-strict +``` + +### Configuration File + +In `primer.config.json`: + +```json +{ + "policies": ["./policies/strict.json"] +} +``` + +Config-sourced policies are restricted to JSON-only for security. + +## Scoring + +The engine computes a score from final recommendations: + +- Each recommendation has an `impact`: critical (5), high (4), medium (3), low (2), info (0) +- Score = 1 - (total deductions / max possible weight) +- Max weight = number of detected signals × 5 (signals with status "not-detected" are excluded) +- Grades: A ≥ 0.9, B ≥ 0.8, C ≥ 0.7, D ≥ 0.6, F < 0.6 + +## Shadow Mode + +> **Status: In development.** Shadow mode infrastructure (`compareShadow`, `writeShadowLog`) is implemented but not yet wired into the production readiness path. The `.primer-cache/shadow-mode.log` file is **not** written during normal `primer readiness` runs. + +Shadow mode is designed to validate the new plugin engine against the legacy system before switching it on by default. When wired in, it will run both paths in parallel and log discrepancies: + +```typescript +import { compareShadow, writeShadowLog } from "primer/services/policy/shadow"; + +// Compare legacy ReadinessReport against a new EngineReport +const result = compareShadow(legacyReport, engineReport, { + repoPath: "/path/to/repo", + useNewEngine: false // Use legacy output by default +}); + +// Write any discrepancies to .primer-cache/shadow-mode.log +if (result.discrepancies.length > 0) { + await writeShadowLog(repoPath, result.discrepancies); +} +``` + +To run the plugin engine alongside the legacy path today, pass `shadow: true` to `runReadinessReport`. This populates `report.engine` with engine signals, recommendations, and score, but does not replace the legacy output: + +```typescript +const report = await runReadinessReport({ repoPath, shadow: true }); +// report.engine contains: signals, recommendations, policyWarnings, score, grade +``` diff --git a/docs/product.md b/docs/product.md new file mode 100644 index 0000000..f433975 --- /dev/null +++ b/docs/product.md @@ -0,0 +1,56 @@ +# Primer — Product Brief + +## The Problem + +AI coding agents are only as effective as the context they receive. Most repositories lack the structured metadata — custom instructions, MCP configs, readiness baselines — that agents need to produce accurate, idiomatic code. Teams adopting Copilot and similar tools hit a cold-start problem: agents generate plausible but wrong code because they don't understand the repo's conventions, architecture, or tooling. + +This gap widens at scale. An organization with hundreds of repos can't manually author instructions for each one, and there's no standard way to measure whether a repo is "AI-ready" or track improvement over time. + +## Who It's For + +- **Platform engineering teams** rolling out AI coding tools across an org — need to assess readiness, set baselines, and track adoption at scale. +- **Individual developers** who want their AI agent to understand their repo's stack, conventions, and architecture from day one. +- **Engineering leadership** evaluating AI readiness across portfolios, with quantifiable maturity levels and policy-driven compliance. + +## What Primer Does + +Primer automates the preparation work that makes AI coding agents effective: + +1. **Assess** — Score any repo against a 9-pillar readiness model spanning repo health (style, build, testing, docs, dev environment, code quality, observability, security) and AI setup (instructions, MCP, agents, skills). Get a maturity level from 1–5. + +2. **Generate** — Use the Copilot SDK to analyze a repo and produce tailored `copilot-instructions.md` or `AGENTS.md` files. Monorepo-aware: generates per-area instructions scoped with `applyTo` globs. + +3. **Evaluate** — Measure the impact of instructions by comparing AI responses with and without them, scored by a judge model. Use as a CI gate to prevent regressions. + +4. **Configure** — Generate `.vscode/mcp.json` and `.vscode/settings.json` so the dev environment is wired for AI from the start. + +5. **Scale** — Batch-process repos across GitHub orgs or Azure DevOps projects. Clone, generate, and open PRs automatically. Produce consolidated readiness reports for leadership. + +## Maturity Model + +Primer's readiness assessment maps repos to a 5-level maturity model: + +| Level | Name | What it means | +| ----- | ------------ | --------------------------------------------------- | +| 1 | Functional | Builds, tests, basic tooling in place | +| 2 | Documented | README, CONTRIBUTING, custom AI instructions exist | +| 3 | Standardized | CI/CD, security policies, CODEOWNERS, observability | +| 4 | Optimized | MCP servers, custom agents, AI skills configured | +| 5 | Autonomous | Full AI-native development with minimal oversight | + +Organizations can define **readiness policies** to customize which criteria are evaluated, override scoring metadata, and set pass-rate thresholds — enforced locally or in CI. + +## How It's Built + +- **TypeScript CLI** (Commander.js) with an interactive TUI (Ink/React) +- **VS Code extension** with tree views, walkthrough, and webview for readiness reports +- **Copilot SDK** for instruction generation using the same models developers already use +- Supports **GitHub** (Octokit) and **Azure DevOps** (REST API) for batch operations and PR creation + +## Key Design Decisions + +- **Instructions are generated, not templated.** Primer uses the Copilot SDK to analyze actual repo content — no generic boilerplate. +- **Readiness is measurable.** The 9-pillar model produces a numeric score and maturity level, making it possible to set org-wide baselines and CI gates. +- **Evaluation closes the loop.** Teams can prove that instructions actually improve AI output, with configurable pass-rate thresholds. +- **Policy-driven compliance.** Policies are composable JSON files that can be checked into repos or distributed org-wide. +- **Batch-first.** Every workflow that works on one repo also works on hundreds — same CLI, same output format. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4f01f80 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,61 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import eslintConfigPrettier from "eslint-config-prettier"; +import importPlugin from "eslint-plugin-import"; +import nPlugin from "eslint-plugin-n"; +import promisePlugin from "eslint-plugin-promise"; +import globals from "globals"; + +const sourceGlobs = ["**/*.{ts,tsx,js,jsx}"]; + +export default [ + { + ignores: [ + "dist/**", + "node_modules/**", + "coverage/**", + "vscode-extension/**", + "eslint.config.js", + "*.config.ts" + ] + }, + js.configs.recommended, + { + files: sourceGlobs, + languageOptions: { + parser: tsParser, + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + ecmaVersion: "latest" + }, + globals: { + ...globals.node + } + }, + plugins: { + "@typescript-eslint": tseslint, + import: importPlugin, + n: nPlugin, + promise: promisePlugin + }, + rules: { + "no-undef": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" } + ], + "@typescript-eslint/consistent-type-imports": ["warn", { prefer: "type-imports" }], + "import/order": [ + "warn", + { + "newlines-between": "always", + alphabetize: { order: "asc", caseInsensitive: true } + } + ] + } + }, + eslintConfigPrettier +]; diff --git a/eval-results.html b/eval-results.html new file mode 100644 index 0000000..abba370 --- /dev/null +++ b/eval-results.html @@ -0,0 +1,9391 @@ + + + + + + Primer Eval Trajectory + + + +

Primer Eval Trajectory

+
+
+
+
+
+
+ + + diff --git a/eval-results.json b/eval-results.json index 765bd07..6184dac 100644 --- a/eval-results.json +++ b/eval-results.json @@ -2,16 +2,9185 @@ "repoPath": "/Users/pierceboggan/Documents/get-ready", "model": "gpt-4.1", "judgeModel": "gpt-4.1", + "runMetrics": { + "startedAt": "2026-02-04T05:50:01.801Z", + "finishedAt": "2026-02-04T05:50:44.924Z", + "durationMs": 43123 + }, "results": [ { - "id": "overview", - "prompt": "Summarize what this application does and list the main entrypoints.", - "expectation": "Mentions the CLI entrypoint in src/index.ts and that this is the Primer CLI.", - "withInstructions": "This application, Primer, is a CLI tool that analyzes your codebase and generates `.github/copilot-instructions.md` files to help AI coding assistants (like Copilot) better understand your project. It detects languages, frameworks, and package managers, and can generate, preview, and save project-specific instructions using the Copilot SDK. \n\n**Main entrypoints:**\n- `src/index.ts` (primary entry)\n- `src/cli.ts` (CLI setup)\n- CLI commands in `src/commands/` (e.g., `analyze.ts`, `generate.ts`, `instructions.tsx`, `tui.tsx`, `pr.ts`, `eval.ts`, etc.)", - "withoutInstructions": "This application, Primer, is a CLI tool that analyzes your codebase and generates `.github/copilot-instructions.md` files to help AI coding assistants (like Copilot) better understand your project. It detects languages, frameworks, and package managers, and uses the Copilot SDK to generate project-specific instructions. \n\n**Main entrypoints:**\n- `src/index.ts`: Primary entrypoint for the CLI.\n- `src/cli.ts`: Sets up the command-line interface using Commander.\n- `src/commands/`: Contains subcommands for analyze, generate, and instructions.\n- `src/ui/tui.tsx`: Entry for the interactive terminal UI (TUI).", + "id": "case-1", + "prompt": "What is the main purpose of the Primer project and what are its key entrypoints?", + "expectation": "Primer is a TypeScript CLI tool for priming repositories for AI-assisted development and evaluation. The main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key directories include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/ for the Ink/React-based terminal UI.", + "withInstructions": "The main purpose of the Primer project is to prime repositories for AI-assisted development and evaluation by generating Copilot instructions, configuration files, and automating repo analysis and GitHub workflows. Its key entrypoints are:\n- src/index.ts (CLI entrypoint)\n- src/cli.ts (wires CLI commands)\n- src/commands/ (CLI subcommands)\n- src/services/ (core logic)\n- src/ui/tui.tsx (Ink/React TUI)", + "withoutInstructions": "Primer is a TypeScript CLI tool designed to prime repositories for AI-assisted development and evaluation. Its main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key entrypoints for functionality include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/tui.tsx for the Ink/React-based terminal UI.", + "verdict": "pass", + "score": 95, + "rationale": "Response B best matches the expectation. It covers the main purpose, entrypoints, and key directories, and adds relevant details about Copilot instructions and automation, which are consistent with the expectation. Response A is accurate but omits some context about the project's broader purpose and automation features.", + "metrics": { + "withoutInstructions": { + "durationMs": 6365, + "tokenUsage": { + "promptTokens": 9926, + "completionTokens": 77, + "totalTokens": 10003 + }, + "toolCalls": { + "count": 1, + "byName": { + "report_intent": 1 + }, + "totalDurationMs": 0 + } + }, + "withInstructions": { + "durationMs": 5783, + "tokenUsage": { + "promptTokens": 10714, + "completionTokens": 96, + "totalTokens": 10810 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "judge": { + "durationMs": 6595, + "tokenUsage": { + "promptTokens": 10112, + "completionTokens": 81, + "totalTokens": 10193 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "totalDurationMs": 20105 + }, + "trajectory": [ + { + "timestampMs": 1770184203132, + "phase": "withoutInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184203132, + "phase": "withoutInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184205608, + "phase": "withoutInstructions", + "type": "user.message", + "data": { + "content": "You are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhat is the main purpose of the Primer project and what are its key entrypoints?", + "transformedContent": "2026-02-04T05:50:05.400Z\n\nYou are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhat is the main purpose of the Primer project and what are its key entrypoints?", + "attachments": [] + } + }, + { + "timestampMs": 1770184205608, + "phase": "withoutInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184205608, + "phase": "withoutInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10529, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 9896, + "outputTokens": 20, + "cacheReadTokens": 3840, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 1933, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q46RvtJkokzvDWC69j0W4cByW2e", + "providerCallId": "F30B:9765B:B52EE:D44ED:6982DE0D", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "assistant.message", + "data": { + "messageId": "97200cac-f77f-4c75-9822-c96230c6b40b", + "content": "", + "toolRequests": [ + { + "toolCallId": "call_XZsq8Hyb0NbdqvtKjSxmVUA1", + "name": "report_intent", + "arguments": { + "intent": "Summarizing project purpose" + }, + "type": "function" + } + ] + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "tool.execution_start", + "data": { + "toolCallId": "call_XZsq8Hyb0NbdqvtKjSxmVUA1", + "toolName": "report_intent", + "arguments": { + "intent": "Summarizing project purpose" + } + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "tool.execution_complete", + "data": { + "toolCallId": "call_XZsq8Hyb0NbdqvtKjSxmVUA1", + "success": true, + "result": { + "content": "Intent logged", + "detailedContent": "Summarizing project purpose" + }, + "toolTelemetry": {} + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "1" + } + }, + { + "timestampMs": 1770184207573, + "phase": "withoutInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10553, + "messagesLength": 4 + } + }, + { + "timestampMs": 1770184208353, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": "Primer", + "totalResponseSizeBytes": 6 + } + }, + { + "timestampMs": 1770184208353, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 9 + } + }, + { + "timestampMs": 1770184208353, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 2, + "deltaPreview": " a", + "totalResponseSizeBytes": 11 + } + }, + { + "timestampMs": 1770184208353, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 5, + "deltaPreview": " Type", + "totalResponseSizeBytes": 16 + } + }, + { + "timestampMs": 1770184208353, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": "Script", + "totalResponseSizeBytes": 22 + } + }, + { + "timestampMs": 1770184208353, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " CLI", + "totalResponseSizeBytes": 26 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 5, + "deltaPreview": " tool", + "totalResponseSizeBytes": 31 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 9, + "deltaPreview": " designed", + "totalResponseSizeBytes": 40 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": " to", + "totalResponseSizeBytes": 43 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": " prime", + "totalResponseSizeBytes": 49 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 13, + "deltaPreview": " repositories", + "totalResponseSizeBytes": 62 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " for", + "totalResponseSizeBytes": 66 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": " AI", + "totalResponseSizeBytes": 69 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 9, + "deltaPreview": "-assisted", + "totalResponseSizeBytes": 78 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 12, + "deltaPreview": " development", + "totalResponseSizeBytes": 90 + } + }, + { + "timestampMs": 1770184208515, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 94 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 11, + "deltaPreview": " evaluation", + "totalResponseSizeBytes": 105 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 106 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " Its", + "totalResponseSizeBytes": 110 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 5, + "deltaPreview": " main", + "totalResponseSizeBytes": 115 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": " entry", + "totalResponseSizeBytes": 121 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 5, + "deltaPreview": "point", + "totalResponseSizeBytes": 126 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 129 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 133 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": "/index", + "totalResponseSizeBytes": 139 + } + }, + { + "timestampMs": 1770184208676, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": ".ts", + "totalResponseSizeBytes": 142 + } + }, + { + "timestampMs": 1770184208832, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 143 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": " which", + "totalResponseSizeBytes": 149 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": " calls", + "totalResponseSizeBytes": 155 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 159 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": "Cli", + "totalResponseSizeBytes": 162 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": " in", + "totalResponseSizeBytes": 165 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 169 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 170 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": "cli", + "totalResponseSizeBytes": 173 + } + }, + { + "timestampMs": 1770184208833, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": ".ts", + "totalResponseSizeBytes": 176 + } + }, + { + "timestampMs": 1770184208993, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 177 + } + }, + { + "timestampMs": 1770184208993, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " Key", + "totalResponseSizeBytes": 181 + } + }, + { + "timestampMs": 1770184208993, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": " entry", + "totalResponseSizeBytes": 187 + } + }, + { + "timestampMs": 1770184208993, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": "points", + "totalResponseSizeBytes": 193 + } + }, + { + "timestampMs": 1770184208993, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " for", + "totalResponseSizeBytes": 197 + } + }, + { + "timestampMs": 1770184208993, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 14, + "deltaPreview": " functionality", + "totalResponseSizeBytes": 211 + } + }, + { + "timestampMs": 1770184208994, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 8, + "deltaPreview": " include", + "totalResponseSizeBytes": 219 + } + }, + { + "timestampMs": 1770184208994, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 223 + } + }, + { + "timestampMs": 1770184208994, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 224 + } + }, + { + "timestampMs": 1770184208994, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 8, + "deltaPreview": "commands", + "totalResponseSizeBytes": 232 + } + }, + { + "timestampMs": 1770184209157, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 233 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " for", + "totalResponseSizeBytes": 237 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " CLI", + "totalResponseSizeBytes": 241 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " sub", + "totalResponseSizeBytes": 245 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 8, + "deltaPreview": "commands", + "totalResponseSizeBytes": 253 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 254 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 258 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 9, + "deltaPreview": "/services", + "totalResponseSizeBytes": 267 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 268 + } + }, + { + "timestampMs": 1770184209158, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " for", + "totalResponseSizeBytes": 272 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 5, + "deltaPreview": " core", + "totalResponseSizeBytes": 277 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": " logic", + "totalResponseSizeBytes": 283 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 284 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 288 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 292 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": "/ui", + "totalResponseSizeBytes": 295 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 2, + "deltaPreview": "/t", + "totalResponseSizeBytes": 297 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 2, + "deltaPreview": "ui", + "totalResponseSizeBytes": 299 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": ".ts", + "totalResponseSizeBytes": 302 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": "x", + "totalResponseSizeBytes": 303 + } + }, + { + "timestampMs": 1770184209317, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " for", + "totalResponseSizeBytes": 307 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 311 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 4, + "deltaPreview": " Ink", + "totalResponseSizeBytes": 315 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 316 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 5, + "deltaPreview": "React", + "totalResponseSizeBytes": 321 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 6, + "deltaPreview": "-based", + "totalResponseSizeBytes": 327 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 9, + "deltaPreview": " terminal", + "totalResponseSizeBytes": 336 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 3, + "deltaPreview": " UI", + "totalResponseSizeBytes": 339 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 340 + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 9926, + "outputTokens": 77, + "cacheReadTokens": 9856, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 1920, + "initiator": "agent", + "apiCallId": "chatcmpl-D5Q47ZTu4zM37MD9Esf2vS0HfSAlp", + "providerCallId": "F30B:9765B:B58D8:D4BB6:6982DE0F", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.message", + "data": { + "messageId": "28037b5e-a1da-4535-8012-e0d7e568d0ba", + "content": "Primer is a TypeScript CLI tool designed to prime repositories for AI-assisted development and evaluation. Its main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key entrypoints for functionality include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/tui.tsx for the Ink/React-based terminal UI.", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184209492, + "phase": "withoutInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "1" + } + }, + { + "timestampMs": 1770184209493, + "phase": "withoutInstructions", + "type": "session.idle", + "data": {} + }, + { + "timestampMs": 1770184209515, + "phase": "withInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184209515, + "phase": "withInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184210213, + "phase": "withInstructions", + "type": "user.message", + "data": { + "content": "You are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhat is the main purpose of the Primer project and what are its key entrypoints?", + "transformedContent": "2026-02-04T05:50:10.175Z\n\nYou are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhat is the main purpose of the Primer project and what are its key entrypoints?", + "attachments": [] + } + }, + { + "timestampMs": 1770184210213, + "phase": "withInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184210213, + "phase": "withInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 11347, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "The", + "totalResponseSizeBytes": 3 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 5, + "deltaPreview": " main", + "totalResponseSizeBytes": 8 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 8, + "deltaPreview": " purpose", + "totalResponseSizeBytes": 16 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": " of", + "totalResponseSizeBytes": 19 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 23 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 7, + "deltaPreview": " Primer", + "totalResponseSizeBytes": 30 + } + }, + { + "timestampMs": 1770184211583, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 8, + "deltaPreview": " project", + "totalResponseSizeBytes": 38 + } + }, + { + "timestampMs": 1770184211722, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 41 + } + }, + { + "timestampMs": 1770184211722, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": " to", + "totalResponseSizeBytes": 44 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": " prime", + "totalResponseSizeBytes": 50 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 13, + "deltaPreview": " repositories", + "totalResponseSizeBytes": 63 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " for", + "totalResponseSizeBytes": 67 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": " AI", + "totalResponseSizeBytes": 70 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 9, + "deltaPreview": "-assisted", + "totalResponseSizeBytes": 79 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 12, + "deltaPreview": " development", + "totalResponseSizeBytes": 91 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 95 + } + }, + { + "timestampMs": 1770184211725, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 11, + "deltaPreview": " evaluation", + "totalResponseSizeBytes": 106 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": " by", + "totalResponseSizeBytes": 109 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 11, + "deltaPreview": " generating", + "totalResponseSizeBytes": 120 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " Cop", + "totalResponseSizeBytes": 124 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": "ilot", + "totalResponseSizeBytes": 128 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 13, + "deltaPreview": " instructions", + "totalResponseSizeBytes": 141 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 142 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 14, + "deltaPreview": " configuration", + "totalResponseSizeBytes": 156 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": " files", + "totalResponseSizeBytes": 162 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 163 + } + }, + { + "timestampMs": 1770184211885, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 167 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": " autom", + "totalResponseSizeBytes": 173 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 5, + "deltaPreview": "ating", + "totalResponseSizeBytes": 178 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 5, + "deltaPreview": " repo", + "totalResponseSizeBytes": 183 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 9, + "deltaPreview": " analysis", + "totalResponseSizeBytes": 192 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 196 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " Git", + "totalResponseSizeBytes": 200 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "Hub", + "totalResponseSizeBytes": 203 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 10, + "deltaPreview": " workflows", + "totalResponseSizeBytes": 213 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 214 + } + }, + { + "timestampMs": 1770184212048, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " Its", + "totalResponseSizeBytes": 218 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " key", + "totalResponseSizeBytes": 222 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": " entry", + "totalResponseSizeBytes": 228 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": "points", + "totalResponseSizeBytes": 234 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " are", + "totalResponseSizeBytes": 238 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": ":\n", + "totalResponseSizeBytes": 240 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 241 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 245 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": "/index", + "totalResponseSizeBytes": 251 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": ".ts", + "totalResponseSizeBytes": 254 + } + }, + { + "timestampMs": 1770184212215, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 256 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "CLI", + "totalResponseSizeBytes": 259 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": " entry", + "totalResponseSizeBytes": 265 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 5, + "deltaPreview": "point", + "totalResponseSizeBytes": 270 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": ")\n", + "totalResponseSizeBytes": 272 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 273 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 277 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 278 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "cli", + "totalResponseSizeBytes": 281 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": ".ts", + "totalResponseSizeBytes": 284 + } + }, + { + "timestampMs": 1770184212377, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 286 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "w", + "totalResponseSizeBytes": 287 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": "ires", + "totalResponseSizeBytes": 291 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " CLI", + "totalResponseSizeBytes": 295 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 9, + "deltaPreview": " commands", + "totalResponseSizeBytes": 304 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": ")\n", + "totalResponseSizeBytes": 306 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 307 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 311 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 312 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 8, + "deltaPreview": "commands", + "totalResponseSizeBytes": 320 + } + }, + { + "timestampMs": 1770184213815, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 321 + } + }, + { + "timestampMs": 1770184213971, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 323 + } + }, + { + "timestampMs": 1770184213972, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "CLI", + "totalResponseSizeBytes": 326 + } + }, + { + "timestampMs": 1770184213972, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " sub", + "totalResponseSizeBytes": 330 + } + }, + { + "timestampMs": 1770184213972, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 8, + "deltaPreview": "commands", + "totalResponseSizeBytes": 338 + } + }, + { + "timestampMs": 1770184213972, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": ")\n", + "totalResponseSizeBytes": 340 + } + }, + { + "timestampMs": 1770184213972, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 341 + } + }, + { + "timestampMs": 1770184213973, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 345 + } + }, + { + "timestampMs": 1770184213973, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 9, + "deltaPreview": "/services", + "totalResponseSizeBytes": 354 + } + }, + { + "timestampMs": 1770184213973, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 355 + } + }, + { + "timestampMs": 1770184213973, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 357 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": "core", + "totalResponseSizeBytes": 361 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 6, + "deltaPreview": " logic", + "totalResponseSizeBytes": 367 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": ")\n", + "totalResponseSizeBytes": 369 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 370 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 374 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "/ui", + "totalResponseSizeBytes": 377 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": "/t", + "totalResponseSizeBytes": 379 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": "ui", + "totalResponseSizeBytes": 381 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": ".ts", + "totalResponseSizeBytes": 384 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "x", + "totalResponseSizeBytes": 385 + } + }, + { + "timestampMs": 1770184215118, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 387 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 3, + "deltaPreview": "Ink", + "totalResponseSizeBytes": 390 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 391 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 5, + "deltaPreview": "React", + "totalResponseSizeBytes": 396 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": " T", + "totalResponseSizeBytes": 398 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 2, + "deltaPreview": "UI", + "totalResponseSizeBytes": 400 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "deltaChars": 1, + "deltaPreview": ")", + "totalResponseSizeBytes": 401 + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10714, + "outputTokens": 96, + "cacheReadTokens": 3840, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 5080, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4AGTscnBzVNO2qtl0nAYsMNFtS", + "providerCallId": "F30B:9765B:B6056:D5480:6982DE12", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.message", + "data": { + "messageId": "87c859d3-de1f-422e-bf2d-c13d9418907c", + "content": "The main purpose of the Primer project is to prime repositories for AI-assisted development and evaluation by generating Copilot instructions, configuration files, and automating repo analysis and GitHub workflows. Its key entrypoints are:\n- src/index.ts (CLI entrypoint)\n- src/cli.ts (wires CLI commands)\n- src/commands/ (CLI subcommands)\n- src/services/ (core logic)\n- src/ui/tui.tsx (Ink/React TUI)", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184215295, + "phase": "withInstructions", + "type": "session.idle", + "data": {} + }, + { + "timestampMs": 1770184215319, + "phase": "judge", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184215319, + "phase": "judge", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184215933, + "phase": "judge", + "type": "user.message", + "data": { + "content": "Evaluate which response best matches the expectation.\n\nExpectation: Primer is a TypeScript CLI tool for priming repositories for AI-assisted development and evaluation. The main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key directories include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/ for the Ink/React-based terminal UI.\n\nResponse A (without custom instructions):\nPrimer is a TypeScript CLI tool designed to prime repositories for AI-assisted development and evaluation. Its main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key entrypoints for functionality include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/tui.tsx for the Ink/React-based terminal UI.\n\nResponse B (with custom instructions):\nThe main purpose of the Primer project is to prime repositories for AI-assisted development and evaluation by generating Copilot instructions, configuration files, and automating repo analysis and GitHub workflows. Its key entrypoints are:\n- src/index.ts (CLI entrypoint)\n- src/cli.ts (wires CLI commands)\n- src/commands/ (CLI subcommands)\n- src/services/ (core logic)\n- src/ui/tui.tsx (Ink/React TUI)\n\nReturn JSON only.", + "transformedContent": "2026-02-04T05:50:15.906Z\n\nEvaluate which response best matches the expectation.\n\nExpectation: Primer is a TypeScript CLI tool for priming repositories for AI-assisted development and evaluation. The main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key directories include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/ for the Ink/React-based terminal UI.\n\nResponse A (without custom instructions):\nPrimer is a TypeScript CLI tool designed to prime repositories for AI-assisted development and evaluation. Its main entrypoint is src/index.ts, which calls runCli in src/cli.ts. Key entrypoints for functionality include src/commands/ for CLI subcommands, src/services/ for core logic, and src/ui/tui.tsx for the Ink/React-based terminal UI.\n\nResponse B (with custom instructions):\nThe main purpose of the Primer project is to prime repositories for AI-assisted development and evaluation by generating Copilot instructions, configuration files, and automating repo analysis and GitHub workflows. Its key entrypoints are:\n- src/index.ts (CLI entrypoint)\n- src/cli.ts (wires CLI commands)\n- src/commands/ (CLI subcommands)\n- src/services/ (core logic)\n- src/ui/tui.tsx (Ink/React TUI)\n\nReturn JSON only.", + "attachments": [] + } + }, + { + "timestampMs": 1770184215933, + "phase": "judge", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184215933, + "phase": "judge", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10745, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184220718, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": "{\n", + "totalResponseSizeBytes": 2 + } + }, + { + "timestampMs": 1770184220718, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 3 + } + }, + { + "timestampMs": 1770184220719, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 5 + } + }, + { + "timestampMs": 1770184220719, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": "ver", + "totalResponseSizeBytes": 8 + } + }, + { + "timestampMs": 1770184220719, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": "dict", + "totalResponseSizeBytes": 12 + } + }, + { + "timestampMs": 1770184220719, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 14 + } + }, + { + "timestampMs": 1770184220719, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 16 + } + }, + { + "timestampMs": 1770184220719, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": "pass", + "totalResponseSizeBytes": 20 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": "\",\n", + "totalResponseSizeBytes": 23 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 24 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 26 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 5, + "deltaPreview": "score", + "totalResponseSizeBytes": 31 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 33 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 34 + } + }, + { + "timestampMs": 1770184220905, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": "95", + "totalResponseSizeBytes": 36 + } + }, + { + "timestampMs": 1770184220906, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": ",\n", + "totalResponseSizeBytes": 38 + } + }, + { + "timestampMs": 1770184220906, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 39 + } + }, + { + "timestampMs": 1770184220906, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 41 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": "r", + "totalResponseSizeBytes": 42 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": "ationale", + "totalResponseSizeBytes": 50 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 52 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 54 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": "Response", + "totalResponseSizeBytes": 62 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " B", + "totalResponseSizeBytes": 64 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 5, + "deltaPreview": " best", + "totalResponseSizeBytes": 69 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": " matches", + "totalResponseSizeBytes": 77 + } + }, + { + "timestampMs": 1770184221080, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 81 + } + }, + { + "timestampMs": 1770184221081, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 12, + "deltaPreview": " expectation", + "totalResponseSizeBytes": 93 + } + }, + { + "timestampMs": 1770184221239, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 94 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": " It", + "totalResponseSizeBytes": 97 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 7, + "deltaPreview": " covers", + "totalResponseSizeBytes": 104 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 108 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 5, + "deltaPreview": " main", + "totalResponseSizeBytes": 113 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": " purpose", + "totalResponseSizeBytes": 121 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 122 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 6, + "deltaPreview": " entry", + "totalResponseSizeBytes": 128 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 6, + "deltaPreview": "points", + "totalResponseSizeBytes": 134 + } + }, + { + "timestampMs": 1770184221240, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 135 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 139 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " key", + "totalResponseSizeBytes": 143 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 12, + "deltaPreview": " directories", + "totalResponseSizeBytes": 155 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 156 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 160 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 5, + "deltaPreview": " adds", + "totalResponseSizeBytes": 165 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 9, + "deltaPreview": " relevant", + "totalResponseSizeBytes": 174 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": " details", + "totalResponseSizeBytes": 182 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 6, + "deltaPreview": " about", + "totalResponseSizeBytes": 188 + } + }, + { + "timestampMs": 1770184221404, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " Cop", + "totalResponseSizeBytes": 192 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": "ilot", + "totalResponseSizeBytes": 196 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 13, + "deltaPreview": " instructions", + "totalResponseSizeBytes": 209 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 213 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 11, + "deltaPreview": " automation", + "totalResponseSizeBytes": 224 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 225 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 6, + "deltaPreview": " which", + "totalResponseSizeBytes": 231 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " are", + "totalResponseSizeBytes": 235 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 11, + "deltaPreview": " consistent", + "totalResponseSizeBytes": 246 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 5, + "deltaPreview": " with", + "totalResponseSizeBytes": 251 + } + }, + { + "timestampMs": 1770184221567, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 255 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 12, + "deltaPreview": " expectation", + "totalResponseSizeBytes": 267 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 268 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 9, + "deltaPreview": " Response", + "totalResponseSizeBytes": 277 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 2, + "deltaPreview": " A", + "totalResponseSizeBytes": 279 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 282 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 9, + "deltaPreview": " accurate", + "totalResponseSizeBytes": 291 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " but", + "totalResponseSizeBytes": 295 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": " om", + "totalResponseSizeBytes": 298 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": "its", + "totalResponseSizeBytes": 301 + } + }, + { + "timestampMs": 1770184221732, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 5, + "deltaPreview": " some", + "totalResponseSizeBytes": 306 + } + }, + { + "timestampMs": 1770184221907, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": " context", + "totalResponseSizeBytes": 314 + } + }, + { + "timestampMs": 1770184221907, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 6, + "deltaPreview": " about", + "totalResponseSizeBytes": 320 + } + }, + { + "timestampMs": 1770184221907, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 324 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 10, + "deltaPreview": " project's", + "totalResponseSizeBytes": 334 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": " broader", + "totalResponseSizeBytes": 342 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 8, + "deltaPreview": " purpose", + "totalResponseSizeBytes": 350 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 354 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 11, + "deltaPreview": " automation", + "totalResponseSizeBytes": 365 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 9, + "deltaPreview": " features", + "totalResponseSizeBytes": 374 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 3, + "deltaPreview": ".\"\n", + "totalResponseSizeBytes": 377 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "deltaChars": 1, + "deltaPreview": "}", + "totalResponseSizeBytes": 378 + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10112, + "outputTokens": 81, + "cacheReadTokens": 9088, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 5977, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4GXqFijypr8Y4ffC964TeLlilJ", + "providerCallId": "F30B:9765B:B70BA:D67D1:6982DE17", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.message", + "data": { + "messageId": "67ad997b-7610-4fba-a0d0-b3d4c0bb1d75", + "content": "{\n \"verdict\": \"pass\",\n \"score\": 95,\n \"rationale\": \"Response B best matches the expectation. It covers the main purpose, entrypoints, and key directories, and adds relevant details about Copilot instructions and automation, which are consistent with the expectation. Response A is accurate but omits some context about the project's broader purpose and automation features.\"\n}", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184221911, + "phase": "judge", + "type": "session.idle", + "data": {} + } + ] + }, + { + "id": "case-2", + "prompt": "How do I build, lint, and test the Primer project locally?", + "expectation": "Install dependencies with npm install. Build with npm run build, lint with npm run lint, and test with npm run test. Type checking is done with npx tsc -p tsconfig.json --noEmit, and coverage with npm run test:coverage.", + "withInstructions": "To build, lint, and test Primer locally, use the existing npm scripts:\n\n- Build: `npm run build`\n- Lint: `npm run lint`\n- Test: `npm run test`\n\nRun these commands from the project root. No extra setup is required beyond `npm install`.", + "withoutInstructions": "To build: run `npm run build` (uses TypeScript compiler). \nTo lint: run `npm run lint` (uses ESLint). \nTo test: run `npm run test` (uses Vitest).", + "verdict": "fail", + "score": 60, + "rationale": "Both responses mention build, lint, and test commands, but neither includes the type checking (npx tsc -p tsconfig.json --noEmit) or coverage (npm run test:coverage) steps as required by the expectation. Response B is slightly more complete and user-friendly, but both omit critical commands, so neither fully matches the expectation.", + "metrics": { + "withoutInstructions": { + "durationMs": 7417, + "tokenUsage": { + "promptTokens": 10278, + "completionTokens": 89, + "totalTokens": 10367 + }, + "toolCalls": { + "count": 4, + "byName": { + "report_intent": 1, + "grep": 3 + }, + "totalDurationMs": 0 + } + }, + "withInstructions": { + "durationMs": 2482, + "tokenUsage": { + "promptTokens": 10711, + "completionTokens": 61, + "totalTokens": 10772 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "judge": { + "durationMs": 4235, + "tokenUsage": { + "promptTokens": 10030, + "completionTokens": 91, + "totalTokens": 10121 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "totalDurationMs": 14196 + }, + "trajectory": [ + { + "timestampMs": 1770184221936, + "phase": "withoutInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184221936, + "phase": "withoutInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184222585, + "phase": "withoutInstructions", + "type": "user.message", + "data": { + "content": "You are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nHow do I build, lint, and test the Primer project locally?", + "transformedContent": "2026-02-04T05:50:22.562Z\n\nYou are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nHow do I build, lint, and test the Primer project locally?", + "attachments": [] + } + }, + { + "timestampMs": 1770184222586, + "phase": "withoutInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184222586, + "phase": "withoutInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10526, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 9893, + "outputTokens": 21, + "cacheReadTokens": 9088, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 3359, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4N09vcn3gbKBho7qwtxANJHqc0", + "providerCallId": "F30B:9765B:B84CF:D7F2A:6982DE1E", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "assistant.message", + "data": { + "messageId": "97025c63-f0bf-4dec-a001-2f12bae4d105", + "content": "", + "toolRequests": [ + { + "toolCallId": "call_NXt0bcYpDuHYzQAaqi4ZUlfP", + "name": "report_intent", + "arguments": { + "intent": "Checking build/lint/test commands" + }, + "type": "function" + } + ] + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "tool.execution_start", + "data": { + "toolCallId": "call_NXt0bcYpDuHYzQAaqi4ZUlfP", + "toolName": "report_intent", + "arguments": { + "intent": "Checking build/lint/test commands" + } + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "tool.execution_complete", + "data": { + "toolCallId": "call_NXt0bcYpDuHYzQAaqi4ZUlfP", + "success": true, + "result": { + "content": "Intent logged", + "detailedContent": "Checking build/lint/test commands" + }, + "toolTelemetry": {} + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "1" + } + }, + { + "timestampMs": 1770184225975, + "phase": "withoutInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10551, + "messagesLength": 4 + } + }, + { + "timestampMs": 1770184227727, + "phase": "withoutInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 9924, + "outputTokens": 89, + "cacheReadTokens": 9856, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 1748, + "initiator": "agent", + "apiCallId": "chatcmpl-D5Q4Q7dnVGgsp6nN4Ex9dxa7Vt1Vi", + "providerCallId": "F30B:9765B:B8F87:D8B58:6982DE21", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184227727, + "phase": "withoutInstructions", + "type": "assistant.message", + "data": { + "messageId": "bb4d3841-caad-432b-bbb6-40c4a9574430", + "content": "", + "toolRequests": [ + { + "toolCallId": "call_s32rB59ZZTfeolhzdWBuw3Ih", + "name": "grep", + "arguments": { + "pattern": "build", + "glob": "package.json", + "output_mode": "content" + }, + "type": "function" + }, + { + "toolCallId": "call_TAHAIp3ggdCEuAXCXwxhcAfB", + "name": "grep", + "arguments": { + "pattern": "lint", + "glob": "package.json", + "output_mode": "content" + }, + "type": "function" + }, + { + "toolCallId": "call_9ycksvzAJGSXJ3T7NQy1OcyO", + "name": "grep", + "arguments": { + "pattern": "test", + "glob": "package.json", + "output_mode": "content" + }, + "type": "function" + } + ] + } + }, + { + "timestampMs": 1770184227727, + "phase": "withoutInstructions", + "type": "tool.execution_start", + "data": { + "toolCallId": "call_s32rB59ZZTfeolhzdWBuw3Ih", + "toolName": "grep", + "arguments": { + "pattern": "build", + "glob": "package.json", + "output_mode": "content" + } + } + }, + { + "timestampMs": 1770184227728, + "phase": "withoutInstructions", + "type": "tool.execution_start", + "data": { + "toolCallId": "call_TAHAIp3ggdCEuAXCXwxhcAfB", + "toolName": "grep", + "arguments": { + "pattern": "lint", + "glob": "package.json", + "output_mode": "content" + } + } + }, + { + "timestampMs": 1770184227728, + "phase": "withoutInstructions", + "type": "tool.execution_start", + "data": { + "toolCallId": "call_9ycksvzAJGSXJ3T7NQy1OcyO", + "toolName": "grep", + "arguments": { + "pattern": "test", + "glob": "package.json", + "output_mode": "content" + } + } + }, + { + "timestampMs": 1770184227764, + "phase": "withoutInstructions", + "type": "tool.execution_complete", + "data": { + "toolCallId": "call_s32rB59ZZTfeolhzdWBuw3Ih", + "success": true, + "result": { + "content": "./package.json: \"build\": \"tsc && npm link\",", + "detailedContent": "./package.json: \"build\": \"tsc && npm link\"," + }, + "toolTelemetry": { + "properties": { + "pattern": "build", + "output_mode": "content", + "matches_found": "true" + }, + "metrics": { + "result_length": 46 + } + } + } + }, + { + "timestampMs": 1770184227764, + "phase": "withoutInstructions", + "type": "tool.execution_complete", + "data": { + "toolCallId": "call_TAHAIp3ggdCEuAXCXwxhcAfB", + "success": true, + "result": { + "content": "./package.json: \"lint\": \"eslint .\",\n./package.json: \"@typescript-eslint/eslint-plugin\": \"^8.5.0\",\n./package.json: \"@typescript-eslint/parser\": \"^8.5.0\",\n./package.json: \"eslint\": \"^9.7.0\",\n./package.json: \"eslint-config-prettier\": \"^9.1.0\",\n./package.json: \"eslint-plugin-import\": \"^2.30.0\",\n./package.json: \"eslint-plugin-n\": \"^17.10.2\",\n./package.json: \"eslint-plugin-promise\": \"^7.1.0\",", + "detailedContent": "./package.json: \"lint\": \"eslint .\",\n./package.json: \"@typescript-eslint/eslint-plugin\": \"^8.5.0\",\n./package.json: \"@typescript-eslint/parser\": \"^8.5.0\",\n./package.json: \"eslint\": \"^9.7.0\",\n./package.json: \"eslint-config-prettier\": \"^9.1.0\",\n./package.json: \"eslint-plugin-import\": \"^2.30.0\",\n./package.json: \"eslint-plugin-n\": \"^17.10.2\",\n./package.json: \"eslint-plugin-promise\": \"^7.1.0\"," + }, + "toolTelemetry": { + "properties": { + "pattern": "lint", + "output_mode": "content", + "matches_found": "true" + }, + "metrics": { + "result_length": 413 + } + } + } + }, + { + "timestampMs": 1770184227797, + "phase": "withoutInstructions", + "type": "tool.execution_complete", + "data": { + "toolCallId": "call_9ycksvzAJGSXJ3T7NQy1OcyO", + "success": true, + "result": { + "content": "./package.json: \"test\": \"vitest run\",\n./package.json: \"test:watch\": \"vitest\",\n./package.json: \"test:coverage\": \"vitest run --coverage\"\n./package.json: \"@vitest/coverage-v8\": \"^2.1.4\",\n./package.json: \"vitest\": \"^2.1.4\"", + "detailedContent": "./package.json: \"test\": \"vitest run\",\n./package.json: \"test:watch\": \"vitest\",\n./package.json: \"test:coverage\": \"vitest run --coverage\"\n./package.json: \"@vitest/coverage-v8\": \"^2.1.4\",\n./package.json: \"vitest\": \"^2.1.4\"" + }, + "toolTelemetry": { + "properties": { + "pattern": "test", + "output_mode": "content", + "matches_found": "true" + }, + "metrics": { + "result_length": 233 + } + } + } + }, + { + "timestampMs": 1770184227798, + "phase": "withoutInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "1" + } + }, + { + "timestampMs": 1770184227798, + "phase": "withoutInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "2" + } + }, + { + "timestampMs": 1770184227798, + "phase": "withoutInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10865, + "messagesLength": 8 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": "To", + "totalResponseSizeBytes": 2 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 6, + "deltaPreview": " build", + "totalResponseSizeBytes": 8 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 9 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 13 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 15 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 18 + } + }, + { + "timestampMs": 1770184228685, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 22 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 6, + "deltaPreview": " build", + "totalResponseSizeBytes": 28 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 1, + "deltaPreview": "`", + "totalResponseSizeBytes": 29 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 31 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": "uses", + "totalResponseSizeBytes": 35 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 5, + "deltaPreview": " Type", + "totalResponseSizeBytes": 40 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 6, + "deltaPreview": "Script", + "totalResponseSizeBytes": 46 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 9, + "deltaPreview": " compiler", + "totalResponseSizeBytes": 55 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": ").", + "totalResponseSizeBytes": 57 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": " \n", + "totalResponseSizeBytes": 60 + } + }, + { + "timestampMs": 1770184228848, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": "To", + "totalResponseSizeBytes": 62 + } + }, + { + "timestampMs": 1770184229008, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 5, + "deltaPreview": " lint", + "totalResponseSizeBytes": 67 + } + }, + { + "timestampMs": 1770184229008, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 68 + } + }, + { + "timestampMs": 1770184229008, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 72 + } + }, + { + "timestampMs": 1770184229008, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 74 + } + }, + { + "timestampMs": 1770184229008, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 77 + } + }, + { + "timestampMs": 1770184229008, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 81 + } + }, + { + "timestampMs": 1770184229009, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 5, + "deltaPreview": " lint", + "totalResponseSizeBytes": 86 + } + }, + { + "timestampMs": 1770184229009, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 1, + "deltaPreview": "`", + "totalResponseSizeBytes": 87 + } + }, + { + "timestampMs": 1770184229009, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 89 + } + }, + { + "timestampMs": 1770184229009, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": "uses", + "totalResponseSizeBytes": 93 + } + }, + { + "timestampMs": 1770184229172, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " ESL", + "totalResponseSizeBytes": 97 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": "int", + "totalResponseSizeBytes": 100 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": ").", + "totalResponseSizeBytes": 102 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": " \n", + "totalResponseSizeBytes": 105 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": "To", + "totalResponseSizeBytes": 107 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 5, + "deltaPreview": " test", + "totalResponseSizeBytes": 112 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 113 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 117 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 119 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 122 + } + }, + { + "timestampMs": 1770184229173, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 126 + } + }, + { + "timestampMs": 1770184229349, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 5, + "deltaPreview": " test", + "totalResponseSizeBytes": 131 + } + }, + { + "timestampMs": 1770184229350, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 1, + "deltaPreview": "`", + "totalResponseSizeBytes": 132 + } + }, + { + "timestampMs": 1770184229350, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 134 + } + }, + { + "timestampMs": 1770184229350, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": "uses", + "totalResponseSizeBytes": 138 + } + }, + { + "timestampMs": 1770184229350, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 4, + "deltaPreview": " Vit", + "totalResponseSizeBytes": 142 + } + }, + { + "timestampMs": 1770184229351, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 3, + "deltaPreview": "est", + "totalResponseSizeBytes": 145 + } + }, + { + "timestampMs": 1770184229351, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "deltaChars": 2, + "deltaPreview": ").", + "totalResponseSizeBytes": 147 + } + }, + { + "timestampMs": 1770184229351, + "phase": "withoutInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10278, + "outputTokens": 47, + "cacheReadTokens": 9984, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 1554, + "initiator": "agent", + "apiCallId": "chatcmpl-D5Q4SkfmNac7XJsi0v0yGSXX1b9sY", + "providerCallId": "F30B:9765B:B94D7:D917F:6982DE23", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184229351, + "phase": "withoutInstructions", + "type": "assistant.message", + "data": { + "messageId": "effb8ff2-b181-47d5-9d4a-baa863978dde", + "content": "To build: run `npm run build` (uses TypeScript compiler). \nTo lint: run `npm run lint` (uses ESLint). \nTo test: run `npm run test` (uses Vitest).", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184229351, + "phase": "withoutInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "2" + } + }, + { + "timestampMs": 1770184229351, + "phase": "withoutInstructions", + "type": "session.idle", + "data": {} + }, + { + "timestampMs": 1770184229380, + "phase": "withInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184229380, + "phase": "withInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184230003, + "phase": "withInstructions", + "type": "user.message", + "data": { + "content": "You are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nHow do I build, lint, and test the Primer project locally?", + "transformedContent": "2026-02-04T05:50:29.962Z\n\nYou are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nHow do I build, lint, and test the Primer project locally?", + "attachments": [] + } + }, + { + "timestampMs": 1770184230003, + "phase": "withInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184230003, + "phase": "withInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 11344, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": "To", + "totalResponseSizeBytes": 2 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 6, + "deltaPreview": " build", + "totalResponseSizeBytes": 8 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 9 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " lint", + "totalResponseSizeBytes": 14 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 15 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 19 + } + }, + { + "timestampMs": 1770184231019, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " test", + "totalResponseSizeBytes": 24 + } + }, + { + "timestampMs": 1770184231195, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 7, + "deltaPreview": " Primer", + "totalResponseSizeBytes": 31 + } + }, + { + "timestampMs": 1770184231195, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 8, + "deltaPreview": " locally", + "totalResponseSizeBytes": 39 + } + }, + { + "timestampMs": 1770184231195, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 40 + } + }, + { + "timestampMs": 1770184231195, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " use", + "totalResponseSizeBytes": 44 + } + }, + { + "timestampMs": 1770184231195, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 48 + } + }, + { + "timestampMs": 1770184231196, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 9, + "deltaPreview": " existing", + "totalResponseSizeBytes": 57 + } + }, + { + "timestampMs": 1770184231196, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " npm", + "totalResponseSizeBytes": 61 + } + }, + { + "timestampMs": 1770184231196, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 8, + "deltaPreview": " scripts", + "totalResponseSizeBytes": 69 + } + }, + { + "timestampMs": 1770184231196, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": ":\n\n", + "totalResponseSizeBytes": 72 + } + }, + { + "timestampMs": 1770184231196, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 73 + } + }, + { + "timestampMs": 1770184231359, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 6, + "deltaPreview": " Build", + "totalResponseSizeBytes": 79 + } + }, + { + "timestampMs": 1770184231359, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 80 + } + }, + { + "timestampMs": 1770184231359, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 82 + } + }, + { + "timestampMs": 1770184231359, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 85 + } + }, + { + "timestampMs": 1770184231359, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 89 + } + }, + { + "timestampMs": 1770184231360, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 6, + "deltaPreview": " build", + "totalResponseSizeBytes": 95 + } + }, + { + "timestampMs": 1770184231360, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": "`\n", + "totalResponseSizeBytes": 97 + } + }, + { + "timestampMs": 1770184231360, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 98 + } + }, + { + "timestampMs": 1770184231360, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": " L", + "totalResponseSizeBytes": 100 + } + }, + { + "timestampMs": 1770184231360, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "int", + "totalResponseSizeBytes": 103 + } + }, + { + "timestampMs": 1770184231520, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 104 + } + }, + { + "timestampMs": 1770184231521, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 106 + } + }, + { + "timestampMs": 1770184231521, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 109 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 113 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " lint", + "totalResponseSizeBytes": 118 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": "`\n", + "totalResponseSizeBytes": 120 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": "-", + "totalResponseSizeBytes": 121 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " Test", + "totalResponseSizeBytes": 126 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 127 + } + }, + { + "timestampMs": 1770184231523, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 129 + } + }, + { + "timestampMs": 1770184231684, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 132 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 136 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " test", + "totalResponseSizeBytes": 141 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "`\n\n", + "totalResponseSizeBytes": 144 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "Run", + "totalResponseSizeBytes": 147 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 6, + "deltaPreview": " these", + "totalResponseSizeBytes": 153 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 9, + "deltaPreview": " commands", + "totalResponseSizeBytes": 162 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " from", + "totalResponseSizeBytes": 167 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 171 + } + }, + { + "timestampMs": 1770184231685, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 8, + "deltaPreview": " project", + "totalResponseSizeBytes": 179 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 5, + "deltaPreview": " root", + "totalResponseSizeBytes": 184 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 185 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": " No", + "totalResponseSizeBytes": 188 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 6, + "deltaPreview": " extra", + "totalResponseSizeBytes": 194 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 6, + "deltaPreview": " setup", + "totalResponseSizeBytes": 200 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 203 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 9, + "deltaPreview": " required", + "totalResponseSizeBytes": 212 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 7, + "deltaPreview": " beyond", + "totalResponseSizeBytes": 219 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": " `", + "totalResponseSizeBytes": 221 + } + }, + { + "timestampMs": 1770184231849, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 224 + } + }, + { + "timestampMs": 1770184231859, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 8, + "deltaPreview": " install", + "totalResponseSizeBytes": 232 + } + }, + { + "timestampMs": 1770184231859, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "deltaChars": 2, + "deltaPreview": "`.", + "totalResponseSizeBytes": 234 + } + }, + { + "timestampMs": 1770184231859, + "phase": "withInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10711, + "outputTokens": 61, + "cacheReadTokens": 9984, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 1857, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4UxYS11dY2UXRqoNoEr1Zq7mSO", + "providerCallId": "F30B:9765B:B9BB4:D996B:6982DE25", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184231859, + "phase": "withInstructions", + "type": "assistant.message", + "data": { + "messageId": "ddfaa17f-838c-49e9-af15-eb0937e347cb", + "content": "To build, lint, and test Primer locally, use the existing npm scripts:\n\n- Build: `npm run build`\n- Lint: `npm run lint`\n- Test: `npm run test`\n\nRun these commands from the project root. No extra setup is required beyond `npm install`.", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184231859, + "phase": "withInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184231859, + "phase": "withInstructions", + "type": "session.idle", + "data": {} + }, + { + "timestampMs": 1770184231875, + "phase": "judge", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184231875, + "phase": "judge", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184232485, + "phase": "judge", + "type": "user.message", + "data": { + "content": "Evaluate which response best matches the expectation.\n\nExpectation: Install dependencies with npm install. Build with npm run build, lint with npm run lint, and test with npm run test. Type checking is done with npx tsc -p tsconfig.json --noEmit, and coverage with npm run test:coverage.\n\nResponse A (without custom instructions):\nTo build: run `npm run build` (uses TypeScript compiler). \nTo lint: run `npm run lint` (uses ESLint). \nTo test: run `npm run test` (uses Vitest).\n\nResponse B (with custom instructions):\nTo build, lint, and test Primer locally, use the existing npm scripts:\n\n- Build: `npm run build`\n- Lint: `npm run lint`\n- Test: `npm run test`\n\nRun these commands from the project root. No extra setup is required beyond `npm install`.\n\nReturn JSON only.", + "transformedContent": "2026-02-04T05:50:32.447Z\n\nEvaluate which response best matches the expectation.\n\nExpectation: Install dependencies with npm install. Build with npm run build, lint with npm run lint, and test with npm run test. Type checking is done with npx tsc -p tsconfig.json --noEmit, and coverage with npm run test:coverage.\n\nResponse A (without custom instructions):\nTo build: run `npm run build` (uses TypeScript compiler). \nTo lint: run `npm run lint` (uses ESLint). \nTo test: run `npm run test` (uses Vitest).\n\nResponse B (with custom instructions):\nTo build, lint, and test Primer locally, use the existing npm scripts:\n\n- Build: `npm run build`\n- Lint: `npm run lint`\n- Test: `npm run test`\n\nRun these commands from the project root. No extra setup is required beyond `npm install`.\n\nReturn JSON only.", + "attachments": [] + } + }, + { + "timestampMs": 1770184232485, + "phase": "judge", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184232485, + "phase": "judge", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10663, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "{\"", + "totalResponseSizeBytes": 2 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": "ver", + "totalResponseSizeBytes": 5 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": "dict", + "totalResponseSizeBytes": 9 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 11 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 13 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": "fail", + "totalResponseSizeBytes": 17 + } + }, + { + "timestampMs": 1770184233270, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "\",", + "totalResponseSizeBytes": 19 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 21 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": "score", + "totalResponseSizeBytes": 26 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 28 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 29 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "60", + "totalResponseSizeBytes": 31 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 32 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 34 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": "r", + "totalResponseSizeBytes": 35 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 8, + "deltaPreview": "ationale", + "totalResponseSizeBytes": 43 + } + }, + { + "timestampMs": 1770184233444, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 45 + } + }, + { + "timestampMs": 1770184233621, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 47 + } + }, + { + "timestampMs": 1770184233621, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": "Both", + "totalResponseSizeBytes": 51 + } + }, + { + "timestampMs": 1770184233621, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 10, + "deltaPreview": " responses", + "totalResponseSizeBytes": 61 + } + }, + { + "timestampMs": 1770184233621, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 8, + "deltaPreview": " mention", + "totalResponseSizeBytes": 69 + } + }, + { + "timestampMs": 1770184233621, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 6, + "deltaPreview": " build", + "totalResponseSizeBytes": 75 + } + }, + { + "timestampMs": 1770184233622, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 76 + } + }, + { + "timestampMs": 1770184233622, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " lint", + "totalResponseSizeBytes": 81 + } + }, + { + "timestampMs": 1770184233622, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 82 + } + }, + { + "timestampMs": 1770184233622, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 86 + } + }, + { + "timestampMs": 1770184233622, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " test", + "totalResponseSizeBytes": 91 + } + }, + { + "timestampMs": 1770184233767, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " commands", + "totalResponseSizeBytes": 100 + } + }, + { + "timestampMs": 1770184233767, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 101 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " but", + "totalResponseSizeBytes": 105 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 8, + "deltaPreview": " neither", + "totalResponseSizeBytes": 113 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " includes", + "totalResponseSizeBytes": 122 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 126 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " type", + "totalResponseSizeBytes": 131 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " checking", + "totalResponseSizeBytes": 140 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 142 + } + }, + { + "timestampMs": 1770184233770, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": "n", + "totalResponseSizeBytes": 143 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "px", + "totalResponseSizeBytes": 145 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " t", + "totalResponseSizeBytes": 147 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "sc", + "totalResponseSizeBytes": 149 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " -", + "totalResponseSizeBytes": 151 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": "p", + "totalResponseSizeBytes": 152 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " ts", + "totalResponseSizeBytes": 155 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 6, + "deltaPreview": "config", + "totalResponseSizeBytes": 161 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": ".json", + "totalResponseSizeBytes": 166 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " --", + "totalResponseSizeBytes": 169 + } + }, + { + "timestampMs": 1770184234467, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": "no", + "totalResponseSizeBytes": 171 + } + }, + { + "timestampMs": 1770184234872, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": "Emit", + "totalResponseSizeBytes": 175 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ")", + "totalResponseSizeBytes": 176 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " or", + "totalResponseSizeBytes": 179 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " coverage", + "totalResponseSizeBytes": 188 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 190 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": "npm", + "totalResponseSizeBytes": 193 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " run", + "totalResponseSizeBytes": 197 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " test", + "totalResponseSizeBytes": 202 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ":", + "totalResponseSizeBytes": 203 + } + }, + { + "timestampMs": 1770184234873, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 8, + "deltaPreview": "coverage", + "totalResponseSizeBytes": 211 + } + }, + { + "timestampMs": 1770184235037, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ")", + "totalResponseSizeBytes": 212 + } + }, + { + "timestampMs": 1770184235037, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 6, + "deltaPreview": " steps", + "totalResponseSizeBytes": 218 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " as", + "totalResponseSizeBytes": 221 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " required", + "totalResponseSizeBytes": 230 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " by", + "totalResponseSizeBytes": 233 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 237 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 12, + "deltaPreview": " expectation", + "totalResponseSizeBytes": 249 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 250 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " Response", + "totalResponseSizeBytes": 259 + } + }, + { + "timestampMs": 1770184235039, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": " B", + "totalResponseSizeBytes": 261 + } + }, + { + "timestampMs": 1770184235604, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 264 + } + }, + { + "timestampMs": 1770184235604, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " slightly", + "totalResponseSizeBytes": 273 + } + }, + { + "timestampMs": 1770184235604, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " more", + "totalResponseSizeBytes": 278 + } + }, + { + "timestampMs": 1770184235604, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " complete", + "totalResponseSizeBytes": 287 + } + }, + { + "timestampMs": 1770184235604, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 291 + } + }, + { + "timestampMs": 1770184235605, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " user", + "totalResponseSizeBytes": 296 + } + }, + { + "timestampMs": 1770184235605, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": "-friendly", + "totalResponseSizeBytes": 305 + } + }, + { + "timestampMs": 1770184235605, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 306 + } + }, + { + "timestampMs": 1770184235605, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " but", + "totalResponseSizeBytes": 310 + } + }, + { + "timestampMs": 1770184235605, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " both", + "totalResponseSizeBytes": 315 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 5, + "deltaPreview": " omit", + "totalResponseSizeBytes": 320 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " critical", + "totalResponseSizeBytes": 329 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 9, + "deltaPreview": " commands", + "totalResponseSizeBytes": 338 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 339 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 3, + "deltaPreview": " so", + "totalResponseSizeBytes": 342 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 8, + "deltaPreview": " neither", + "totalResponseSizeBytes": 350 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 6, + "deltaPreview": " fully", + "totalResponseSizeBytes": 356 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 8, + "deltaPreview": " matches", + "totalResponseSizeBytes": 364 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 368 + } + }, + { + "timestampMs": 1770184236087, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 12, + "deltaPreview": " expectation", + "totalResponseSizeBytes": 380 + } + }, + { + "timestampMs": 1770184236107, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 2, + "deltaPreview": ".\"", + "totalResponseSizeBytes": 382 + } + }, + { + "timestampMs": 1770184236107, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "deltaChars": 1, + "deltaPreview": "}", + "totalResponseSizeBytes": 383 + } + }, + { + "timestampMs": 1770184236107, + "phase": "judge", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10030, + "outputTokens": 91, + "cacheReadTokens": 9088, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 3621, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4WqKm3J4Ph8bL8tx4KsQly09KT", + "providerCallId": "F30B:9765B:BA390:DA261:6982DE28", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184236107, + "phase": "judge", + "type": "assistant.message", + "data": { + "messageId": "18e636e5-7f9b-4820-9c9b-2770a45fbb4a", + "content": "{\"verdict\": \"fail\", \"score\": 60, \"rationale\": \"Both responses mention build, lint, and test commands, but neither includes the type checking (npx tsc -p tsconfig.json --noEmit) or coverage (npm run test:coverage) steps as required by the expectation. Response B is slightly more complete and user-friendly, but both omit critical commands, so neither fully matches the expectation.\"}", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184236107, + "phase": "judge", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184236107, + "phase": "judge", + "type": "session.idle", + "data": {} + } + ] + }, + { + "id": "case-3", + "prompt": "Where should new CLI commands and core logic be placed, and what conventions should be followed for configuration files?", + "expectation": "New CLI commands should be placed in src/commands/, and core logic in src/services/. Use ESM syntax everywhere, strict TypeScript targeting ES2022 and module ESNext, and only overwrite config files like .vscode/settings.json and .vscode/mcp.json with --force. All Copilot/VS Code settings reference .github/copilot-instructions.md and enable MCP.", + "withInstructions": "New CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files like .vscode/settings.json and .vscode/mcp.json, only overwrite them with --force, and ensure all Copilot/VS Code settings reference the main copilot-instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.", + "withoutInstructions": "New CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files, only overwrite them (e.g., .vscode/settings.json, .vscode/mcp.json) with --force, and all Copilot/VS Code settings should reference the main instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.", "verdict": "pass", "score": 100, - "rationale": "Both responses mention the foCLI entrypoint in src/index.ts and that this is the Primer CLI. Response B is slightly more complete, but both meet the expectation." + "rationale": "Both responses accurately reflect the expectation, but Response B is slightly more precise by explicitly mentioning the copilot-instructions file and the config files. Both responses match the required conventions and instructions, with no significant omissions or errors.", + "metrics": { + "withoutInstructions": { + "durationMs": 2946, + "tokenUsage": { + "promptTokens": 9900, + "completionTokens": 85, + "totalTokens": 9985 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "withInstructions": { + "durationMs": 3189, + "tokenUsage": { + "promptTokens": 10718, + "completionTokens": 84, + "totalTokens": 10802 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "judge": { + "durationMs": 2608, + "tokenUsage": { + "promptTokens": 10116, + "completionTokens": 69, + "totalTokens": 10185 + }, + "toolCalls": { + "count": 0, + "byName": {}, + "totalDurationMs": 0 + } + }, + "totalDurationMs": 8815 + }, + "trajectory": [ + { + "timestampMs": 1770184236135, + "phase": "withoutInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184236135, + "phase": "withoutInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184236736, + "phase": "withoutInstructions", + "type": "user.message", + "data": { + "content": "You are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhere should new CLI commands and core logic be placed, and what conventions should be followed for configuration files?", + "transformedContent": "2026-02-04T05:50:36.710Z\n\nYou are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhere should new CLI commands and core logic be placed, and what conventions should be followed for configuration files?", + "attachments": [] + } + }, + { + "timestampMs": 1770184236736, + "phase": "withoutInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184236736, + "phase": "withoutInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10533, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184237817, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 3, + "deltaPreview": "New", + "totalResponseSizeBytes": 3 + } + }, + { + "timestampMs": 1770184237817, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " CLI", + "totalResponseSizeBytes": 7 + } + }, + { + "timestampMs": 1770184237817, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 9, + "deltaPreview": " commands", + "totalResponseSizeBytes": 16 + } + }, + { + "timestampMs": 1770184237817, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " should", + "totalResponseSizeBytes": 23 + } + }, + { + "timestampMs": 1770184237817, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 3, + "deltaPreview": " be", + "totalResponseSizeBytes": 26 + } + }, + { + "timestampMs": 1770184237817, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " placed", + "totalResponseSizeBytes": 33 + } + }, + { + "timestampMs": 1770184237932, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 3, + "deltaPreview": " in", + "totalResponseSizeBytes": 36 + } + }, + { + "timestampMs": 1770184237932, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 40 + } + }, + { + "timestampMs": 1770184237933, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 41 + } + }, + { + "timestampMs": 1770184237933, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 8, + "deltaPreview": "commands", + "totalResponseSizeBytes": 49 + } + }, + { + "timestampMs": 1770184237933, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": "/,", + "totalResponseSizeBytes": 51 + } + }, + { + "timestampMs": 1770184237933, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 55 + } + }, + { + "timestampMs": 1770184237935, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " core", + "totalResponseSizeBytes": 60 + } + }, + { + "timestampMs": 1770184237935, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 6, + "deltaPreview": " logic", + "totalResponseSizeBytes": 66 + } + }, + { + "timestampMs": 1770184237935, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " should", + "totalResponseSizeBytes": 73 + } + }, + { + "timestampMs": 1770184237935, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 3, + "deltaPreview": " go", + "totalResponseSizeBytes": 76 + } + }, + { + "timestampMs": 1770184238094, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 3, + "deltaPreview": " in", + "totalResponseSizeBytes": 79 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 83 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 9, + "deltaPreview": "/services", + "totalResponseSizeBytes": 92 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": "/.", + "totalResponseSizeBytes": 94 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " For", + "totalResponseSizeBytes": 98 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 14, + "deltaPreview": " configuration", + "totalResponseSizeBytes": 112 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 6, + "deltaPreview": " files", + "totalResponseSizeBytes": 118 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 119 + } + }, + { + "timestampMs": 1770184238095, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " only", + "totalResponseSizeBytes": 124 + } + }, + { + "timestampMs": 1770184238096, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 10, + "deltaPreview": " overwrite", + "totalResponseSizeBytes": 134 + } + }, + { + "timestampMs": 1770184238261, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " them", + "totalResponseSizeBytes": 139 + } + }, + { + "timestampMs": 1770184238261, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": " (", + "totalResponseSizeBytes": 141 + } + }, + { + "timestampMs": 1770184238261, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": "e", + "totalResponseSizeBytes": 142 + } + }, + { + "timestampMs": 1770184238261, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": ".g", + "totalResponseSizeBytes": 144 + } + }, + { + "timestampMs": 1770184238262, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": ".,", + "totalResponseSizeBytes": 146 + } + }, + { + "timestampMs": 1770184238262, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": " .", + "totalResponseSizeBytes": 148 + } + }, + { + "timestampMs": 1770184238262, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": "v", + "totalResponseSizeBytes": 149 + } + }, + { + "timestampMs": 1770184238262, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": "scode", + "totalResponseSizeBytes": 154 + } + }, + { + "timestampMs": 1770184238262, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 9, + "deltaPreview": "/settings", + "totalResponseSizeBytes": 163 + } + }, + { + "timestampMs": 1770184238262, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": ".json", + "totalResponseSizeBytes": 168 + } + }, + { + "timestampMs": 1770184238420, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 169 + } + }, + { + "timestampMs": 1770184238420, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": " .", + "totalResponseSizeBytes": 171 + } + }, + { + "timestampMs": 1770184238420, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": "v", + "totalResponseSizeBytes": 172 + } + }, + { + "timestampMs": 1770184238420, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": "scode", + "totalResponseSizeBytes": 177 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": "/m", + "totalResponseSizeBytes": 179 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": "cp", + "totalResponseSizeBytes": 181 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": ".json", + "totalResponseSizeBytes": 186 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ")", + "totalResponseSizeBytes": 187 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " with", + "totalResponseSizeBytes": 192 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 3, + "deltaPreview": " --", + "totalResponseSizeBytes": 195 + } + }, + { + "timestampMs": 1770184238421, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": "force", + "totalResponseSizeBytes": 200 + } + }, + { + "timestampMs": 1770184238630, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 201 + } + }, + { + "timestampMs": 1770184238630, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 205 + } + }, + { + "timestampMs": 1770184238630, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " all", + "totalResponseSizeBytes": 209 + } + }, + { + "timestampMs": 1770184238630, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " Cop", + "totalResponseSizeBytes": 213 + } + }, + { + "timestampMs": 1770184238631, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": "ilot", + "totalResponseSizeBytes": 217 + } + }, + { + "timestampMs": 1770184238631, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 218 + } + }, + { + "timestampMs": 1770184238631, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": "VS", + "totalResponseSizeBytes": 220 + } + }, + { + "timestampMs": 1770184238631, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " Code", + "totalResponseSizeBytes": 225 + } + }, + { + "timestampMs": 1770184238631, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 9, + "deltaPreview": " settings", + "totalResponseSizeBytes": 234 + } + }, + { + "timestampMs": 1770184238631, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " should", + "totalResponseSizeBytes": 241 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 10, + "deltaPreview": " reference", + "totalResponseSizeBytes": 251 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 255 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " main", + "totalResponseSizeBytes": 260 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 13, + "deltaPreview": " instructions", + "totalResponseSizeBytes": 273 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " file", + "totalResponseSizeBytes": 278 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 282 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " enable", + "totalResponseSizeBytes": 289 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " MCP", + "totalResponseSizeBytes": 293 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 294 + } + }, + { + "timestampMs": 1770184238790, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " Use", + "totalResponseSizeBytes": 298 + } + }, + { + "timestampMs": 1770184238949, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": " E", + "totalResponseSizeBytes": 300 + } + }, + { + "timestampMs": 1770184238949, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 2, + "deltaPreview": "SM", + "totalResponseSizeBytes": 302 + } + }, + { + "timestampMs": 1770184238949, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " syntax", + "totalResponseSizeBytes": 309 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 11, + "deltaPreview": " everywhere", + "totalResponseSizeBytes": 320 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 321 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " strict", + "totalResponseSizeBytes": 328 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " Type", + "totalResponseSizeBytes": 333 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 6, + "deltaPreview": "Script", + "totalResponseSizeBytes": 339 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 340 + } + }, + { + "timestampMs": 1770184238950, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 344 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 7, + "deltaPreview": " follow", + "totalResponseSizeBytes": 351 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " safe", + "totalResponseSizeBytes": 356 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 5, + "deltaPreview": " file", + "totalResponseSizeBytes": 361 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 6, + "deltaPreview": " write", + "totalResponseSizeBytes": 367 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 12, + "deltaPreview": " conventions", + "totalResponseSizeBytes": 379 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 380 + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 9900, + "outputTokens": 85, + "cacheReadTokens": 9088, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 2342, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4bqEMJMkUWnQOI96LI4BYwIYTH", + "providerCallId": "F30B:9765B:BB0D6:DB1F4:6982DE2C", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.message", + "data": { + "messageId": "a4517944-2205-4b00-9258-5d1418814388", + "content": "New CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files, only overwrite them (e.g., .vscode/settings.json, .vscode/mcp.json) with --force, and all Copilot/VS Code settings should reference the main instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184239078, + "phase": "withoutInstructions", + "type": "session.idle", + "data": {} + }, + { + "timestampMs": 1770184239101, + "phase": "withInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184239101, + "phase": "withInstructions", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184239841, + "phase": "withInstructions", + "type": "user.message", + "data": { + "content": "You are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhere should new CLI commands and core logic be placed, and what conventions should be followed for configuration files?", + "transformedContent": "2026-02-04T05:50:39.807Z\n\nYou are working in this repository:\n/Users/pierceboggan/Documents/get-ready\nUse the file system tools when needed to inspect the codebase.\n\nWhere should new CLI commands and core logic be placed, and what conventions should be followed for configuration files?", + "attachments": [] + } + }, + { + "timestampMs": 1770184239841, + "phase": "withInstructions", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184239841, + "phase": "withInstructions", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 11351, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184240932, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": "New", + "totalResponseSizeBytes": 3 + } + }, + { + "timestampMs": 1770184240932, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " CLI", + "totalResponseSizeBytes": 7 + } + }, + { + "timestampMs": 1770184240932, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 9, + "deltaPreview": " commands", + "totalResponseSizeBytes": 16 + } + }, + { + "timestampMs": 1770184240932, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " should", + "totalResponseSizeBytes": 23 + } + }, + { + "timestampMs": 1770184240932, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": " be", + "totalResponseSizeBytes": 26 + } + }, + { + "timestampMs": 1770184240932, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " placed", + "totalResponseSizeBytes": 33 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": " in", + "totalResponseSizeBytes": 36 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 40 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 41 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 8, + "deltaPreview": "commands", + "totalResponseSizeBytes": 49 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": "/,", + "totalResponseSizeBytes": 51 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 55 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " core", + "totalResponseSizeBytes": 60 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 6, + "deltaPreview": " logic", + "totalResponseSizeBytes": 66 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " should", + "totalResponseSizeBytes": 73 + } + }, + { + "timestampMs": 1770184241100, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": " go", + "totalResponseSizeBytes": 76 + } + }, + { + "timestampMs": 1770184241259, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": " in", + "totalResponseSizeBytes": 79 + } + }, + { + "timestampMs": 1770184241259, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " src", + "totalResponseSizeBytes": 83 + } + }, + { + "timestampMs": 1770184241262, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 9, + "deltaPreview": "/services", + "totalResponseSizeBytes": 92 + } + }, + { + "timestampMs": 1770184241262, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": "/.", + "totalResponseSizeBytes": 94 + } + }, + { + "timestampMs": 1770184241262, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " For", + "totalResponseSizeBytes": 98 + } + }, + { + "timestampMs": 1770184241262, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 14, + "deltaPreview": " configuration", + "totalResponseSizeBytes": 112 + } + }, + { + "timestampMs": 1770184241262, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 6, + "deltaPreview": " files", + "totalResponseSizeBytes": 118 + } + }, + { + "timestampMs": 1770184241262, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " like", + "totalResponseSizeBytes": 123 + } + }, + { + "timestampMs": 1770184241263, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": " .", + "totalResponseSizeBytes": 125 + } + }, + { + "timestampMs": 1770184241263, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": "v", + "totalResponseSizeBytes": 126 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": "scode", + "totalResponseSizeBytes": 131 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 9, + "deltaPreview": "/settings", + "totalResponseSizeBytes": 140 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": ".json", + "totalResponseSizeBytes": 145 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 149 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": " .", + "totalResponseSizeBytes": 151 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": "v", + "totalResponseSizeBytes": 152 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": "scode", + "totalResponseSizeBytes": 157 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": "/m", + "totalResponseSizeBytes": 159 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": "cp", + "totalResponseSizeBytes": 161 + } + }, + { + "timestampMs": 1770184241450, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": ".json", + "totalResponseSizeBytes": 166 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 167 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " only", + "totalResponseSizeBytes": 172 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 10, + "deltaPreview": " overwrite", + "totalResponseSizeBytes": 182 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " them", + "totalResponseSizeBytes": 187 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " with", + "totalResponseSizeBytes": 192 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": " --", + "totalResponseSizeBytes": 195 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": "force", + "totalResponseSizeBytes": 200 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 201 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 205 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " ensure", + "totalResponseSizeBytes": 212 + } + }, + { + "timestampMs": 1770184241683, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " all", + "totalResponseSizeBytes": 216 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " Cop", + "totalResponseSizeBytes": 220 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": "ilot", + "totalResponseSizeBytes": 224 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": "/", + "totalResponseSizeBytes": 225 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": "VS", + "totalResponseSizeBytes": 227 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " Code", + "totalResponseSizeBytes": 232 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 9, + "deltaPreview": " settings", + "totalResponseSizeBytes": 241 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 10, + "deltaPreview": " reference", + "totalResponseSizeBytes": 251 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 255 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " main", + "totalResponseSizeBytes": 260 + } + }, + { + "timestampMs": 1770184241847, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " cop", + "totalResponseSizeBytes": 264 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": "ilot", + "totalResponseSizeBytes": 268 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 3, + "deltaPreview": "-in", + "totalResponseSizeBytes": 271 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 10, + "deltaPreview": "structions", + "totalResponseSizeBytes": 281 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " file", + "totalResponseSizeBytes": 286 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 290 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " enable", + "totalResponseSizeBytes": 297 + } + }, + { + "timestampMs": 1770184242013, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " MCP", + "totalResponseSizeBytes": 301 + } + }, + { + "timestampMs": 1770184242014, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 302 + } + }, + { + "timestampMs": 1770184242014, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " Use", + "totalResponseSizeBytes": 306 + } + }, + { + "timestampMs": 1770184242014, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": " E", + "totalResponseSizeBytes": 308 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 2, + "deltaPreview": "SM", + "totalResponseSizeBytes": 310 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " syntax", + "totalResponseSizeBytes": 317 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 11, + "deltaPreview": " everywhere", + "totalResponseSizeBytes": 328 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 329 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " strict", + "totalResponseSizeBytes": 336 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " Type", + "totalResponseSizeBytes": 341 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 6, + "deltaPreview": "Script", + "totalResponseSizeBytes": 347 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 348 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 352 + } + }, + { + "timestampMs": 1770184242176, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 7, + "deltaPreview": " follow", + "totalResponseSizeBytes": 359 + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " safe", + "totalResponseSizeBytes": 364 + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 5, + "deltaPreview": " file", + "totalResponseSizeBytes": 369 + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 6, + "deltaPreview": " write", + "totalResponseSizeBytes": 375 + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 12, + "deltaPreview": " conventions", + "totalResponseSizeBytes": 387 + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.message_delta", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 388 + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10718, + "outputTokens": 84, + "cacheReadTokens": 9984, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 2446, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4etdA0iKp1GopzUuy9TjAQajTf", + "providerCallId": "F30B:9765B:BBA56:DBCEF:6982DE2F", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.message", + "data": { + "messageId": "fe6bb094-be6d-4b79-ac63-ec0e39f62612", + "content": "New CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files like .vscode/settings.json and .vscode/mcp.json, only overwrite them with --force, and ensure all Copilot/VS Code settings reference the main copilot-instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184242288, + "phase": "withInstructions", + "type": "session.idle", + "data": {} + }, + { + "timestampMs": 1770184242317, + "phase": "judge", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184242317, + "phase": "judge", + "type": "pending_messages.modified", + "data": {} + }, + { + "timestampMs": 1770184242973, + "phase": "judge", + "type": "user.message", + "data": { + "content": "Evaluate which response best matches the expectation.\n\nExpectation: New CLI commands should be placed in src/commands/, and core logic in src/services/. Use ESM syntax everywhere, strict TypeScript targeting ES2022 and module ESNext, and only overwrite config files like .vscode/settings.json and .vscode/mcp.json with --force. All Copilot/VS Code settings reference .github/copilot-instructions.md and enable MCP.\n\nResponse A (without custom instructions):\nNew CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files, only overwrite them (e.g., .vscode/settings.json, .vscode/mcp.json) with --force, and all Copilot/VS Code settings should reference the main instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.\n\nResponse B (with custom instructions):\nNew CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files like .vscode/settings.json and .vscode/mcp.json, only overwrite them with --force, and ensure all Copilot/VS Code settings reference the main copilot-instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.\n\nReturn JSON only.", + "transformedContent": "2026-02-04T05:50:42.950Z\n\nEvaluate which response best matches the expectation.\n\nExpectation: New CLI commands should be placed in src/commands/, and core logic in src/services/. Use ESM syntax everywhere, strict TypeScript targeting ES2022 and module ESNext, and only overwrite config files like .vscode/settings.json and .vscode/mcp.json with --force. All Copilot/VS Code settings reference .github/copilot-instructions.md and enable MCP.\n\nResponse A (without custom instructions):\nNew CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files, only overwrite them (e.g., .vscode/settings.json, .vscode/mcp.json) with --force, and all Copilot/VS Code settings should reference the main instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.\n\nResponse B (with custom instructions):\nNew CLI commands should be placed in src/commands/, and core logic should go in src/services/. For configuration files like .vscode/settings.json and .vscode/mcp.json, only overwrite them with --force, and ensure all Copilot/VS Code settings reference the main copilot-instructions file and enable MCP. Use ESM syntax everywhere, strict TypeScript, and follow safe file write conventions.\n\nReturn JSON only.", + "attachments": [] + } + }, + { + "timestampMs": 1770184242973, + "phase": "judge", + "type": "assistant.turn_start", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184242973, + "phase": "judge", + "type": "session.usage_info", + "data": { + "tokenLimit": 64000, + "currentTokens": 10749, + "messagesLength": 2 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": "{\n", + "totalResponseSizeBytes": 2 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 3 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 5 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": "ver", + "totalResponseSizeBytes": 8 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": "dict", + "totalResponseSizeBytes": 12 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 14 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 16 + } + }, + { + "timestampMs": 1770184243938, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": "pass", + "totalResponseSizeBytes": 20 + } + }, + { + "timestampMs": 1770184244096, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": "\",\n", + "totalResponseSizeBytes": 23 + } + }, + { + "timestampMs": 1770184244096, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 24 + } + }, + { + "timestampMs": 1770184244098, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 26 + } + }, + { + "timestampMs": 1770184244098, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 5, + "deltaPreview": "score", + "totalResponseSizeBytes": 31 + } + }, + { + "timestampMs": 1770184244098, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 33 + } + }, + { + "timestampMs": 1770184244098, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 34 + } + }, + { + "timestampMs": 1770184244098, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": "100", + "totalResponseSizeBytes": 37 + } + }, + { + "timestampMs": 1770184244099, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": ",\n", + "totalResponseSizeBytes": 39 + } + }, + { + "timestampMs": 1770184244099, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": " ", + "totalResponseSizeBytes": 40 + } + }, + { + "timestampMs": 1770184244099, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 42 + } + }, + { + "timestampMs": 1770184244261, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": "r", + "totalResponseSizeBytes": 43 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 8, + "deltaPreview": "ationale", + "totalResponseSizeBytes": 51 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": "\":", + "totalResponseSizeBytes": 53 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": " \"", + "totalResponseSizeBytes": 55 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": "Both", + "totalResponseSizeBytes": 59 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 10, + "deltaPreview": " responses", + "totalResponseSizeBytes": 69 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 11, + "deltaPreview": " accurately", + "totalResponseSizeBytes": 80 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 8, + "deltaPreview": " reflect", + "totalResponseSizeBytes": 88 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 92 + } + }, + { + "timestampMs": 1770184244262, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 12, + "deltaPreview": " expectation", + "totalResponseSizeBytes": 104 + } + }, + { + "timestampMs": 1770184244422, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 105 + } + }, + { + "timestampMs": 1770184244422, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " but", + "totalResponseSizeBytes": 109 + } + }, + { + "timestampMs": 1770184244422, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 9, + "deltaPreview": " Response", + "totalResponseSizeBytes": 118 + } + }, + { + "timestampMs": 1770184244422, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 2, + "deltaPreview": " B", + "totalResponseSizeBytes": 120 + } + }, + { + "timestampMs": 1770184244422, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": " is", + "totalResponseSizeBytes": 123 + } + }, + { + "timestampMs": 1770184244423, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 9, + "deltaPreview": " slightly", + "totalResponseSizeBytes": 132 + } + }, + { + "timestampMs": 1770184244423, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 5, + "deltaPreview": " more", + "totalResponseSizeBytes": 137 + } + }, + { + "timestampMs": 1770184244423, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 8, + "deltaPreview": " precise", + "totalResponseSizeBytes": 145 + } + }, + { + "timestampMs": 1770184244423, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": " by", + "totalResponseSizeBytes": 148 + } + }, + { + "timestampMs": 1770184244423, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 11, + "deltaPreview": " explicitly", + "totalResponseSizeBytes": 159 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 11, + "deltaPreview": " mentioning", + "totalResponseSizeBytes": 170 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 174 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " cop", + "totalResponseSizeBytes": 178 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": "ilot", + "totalResponseSizeBytes": 182 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": "-in", + "totalResponseSizeBytes": 185 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 10, + "deltaPreview": "structions", + "totalResponseSizeBytes": 195 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 5, + "deltaPreview": " file", + "totalResponseSizeBytes": 200 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 204 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 208 + } + }, + { + "timestampMs": 1770184244586, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 7, + "deltaPreview": " config", + "totalResponseSizeBytes": 215 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 6, + "deltaPreview": " files", + "totalResponseSizeBytes": 221 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": ".", + "totalResponseSizeBytes": 222 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 5, + "deltaPreview": " Both", + "totalResponseSizeBytes": 227 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 10, + "deltaPreview": " responses", + "totalResponseSizeBytes": 237 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 6, + "deltaPreview": " match", + "totalResponseSizeBytes": 243 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " the", + "totalResponseSizeBytes": 247 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 9, + "deltaPreview": " required", + "totalResponseSizeBytes": 256 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 12, + "deltaPreview": " conventions", + "totalResponseSizeBytes": 268 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 4, + "deltaPreview": " and", + "totalResponseSizeBytes": 272 + } + }, + { + "timestampMs": 1770184244746, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 13, + "deltaPreview": " instructions", + "totalResponseSizeBytes": 285 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": ",", + "totalResponseSizeBytes": 286 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 5, + "deltaPreview": " with", + "totalResponseSizeBytes": 291 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": " no", + "totalResponseSizeBytes": 294 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 12, + "deltaPreview": " significant", + "totalResponseSizeBytes": 306 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 10, + "deltaPreview": " omissions", + "totalResponseSizeBytes": 316 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": " or", + "totalResponseSizeBytes": 319 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 7, + "deltaPreview": " errors", + "totalResponseSizeBytes": 326 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 3, + "deltaPreview": ".\"\n", + "totalResponseSizeBytes": 329 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message_delta", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "deltaChars": 1, + "deltaPreview": "}", + "totalResponseSizeBytes": 330 + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.usage", + "data": { + "model": "gpt-4.1", + "inputTokens": 10116, + "outputTokens": 69, + "cacheReadTokens": 9088, + "cacheWriteTokens": 0, + "cost": 0, + "duration": 1949, + "initiator": "user", + "apiCallId": "chatcmpl-D5Q4hzEi5ijMG2ws1HHHyPmK9qadB", + "providerCallId": "F30B:9765B:BC404:DC81F:6982DE32", + "quotaSnapshots": { + "chat": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "completions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + }, + "premium_interactions_tbb": { + "isUnlimitedEntitlement": true, + "entitlementRequests": -1, + "usedRequests": 0, + "usageAllowedWithExhaustedQuota": true, + "overage": 0, + "overageAllowedWithExhaustedQuota": true, + "remainingPercentage": 100, + "resetDate": "2026-03-01T00:00:00.000Z" + } + } + } + }, + { + "timestampMs": 1770184244922, + "phase": "judge", + "type": "assistant.message", + "data": { + "messageId": "f401f59a-7133-428b-baa2-c50a3e82b1f5", + "content": "{\n \"verdict\": \"pass\",\n \"score\": 100,\n \"rationale\": \"Both responses accurately reflect the expectation, but Response B is slightly more precise by explicitly mentioning the copilot-instructions file and the config files. Both responses match the required conventions and instructions, with no significant omissions or errors.\"\n}", + "toolRequests": [] + } + }, + { + "timestampMs": 1770184244923, + "phase": "judge", + "type": "assistant.turn_end", + "data": { + "turnId": "0" + } + }, + { + "timestampMs": 1770184244923, + "phase": "judge", + "type": "session.idle", + "data": {} + } + ] } ] -} \ No newline at end of file +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..7d8a2f7 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,25 @@ +# Primer Examples + +This folder includes quick examples to help you get started. + +## CLI usage + +```bash +# Interactive setup +primer init + +# AI readiness report +primer readiness /path/to/repo +primer readiness --visual + +# Generate instructions +primer instructions --repo /path/to/repo + +# Scaffold and run evals +primer eval --init --repo /path/to/repo +primer eval primer.eval.json --repo /path/to/repo +``` + +## Sample eval config + +See `primer.eval.json` for a starter eval config you can customize. diff --git a/examples/policies/README.md b/examples/policies/README.md new file mode 100644 index 0000000..8f654c2 --- /dev/null +++ b/examples/policies/README.md @@ -0,0 +1,26 @@ +# Example Policies + +Readiness policies customize which criteria are evaluated and how they're scored. + +## Usage + +Pass a policy file with `--policy` using a relative `./` path: + +```sh +primer readiness --policy ./examples/policies/ai-only.json +primer readiness --policy ./examples/policies/strict.json +``` + +Multiple policies can be chained (comma-separated): + +```sh +primer readiness --policy ./examples/policies/ai-only.json,./my-overrides.json +``` + +## Included Policies + +| File | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------ | +| `ai-only.json` | Disables all repo-health criteria, focusing only on AI tooling readiness | +| `repo-health-only.json` | Disables all AI-tooling criteria and the `agents-doc` extra, focusing on repo health | +| `strict.json` | Sets 100% pass-rate threshold and elevates several criteria to high impact | diff --git a/examples/policies/ai-only.json b/examples/policies/ai-only.json new file mode 100644 index 0000000..71292bb --- /dev/null +++ b/examples/policies/ai-only.json @@ -0,0 +1,25 @@ +{ + "name": "ai-only", + "criteria": { + "disable": [ + "lint-config", + "typecheck-config", + "build-script", + "ci-config", + "test-script", + "readme", + "contributing", + "lockfile", + "env-example", + "format-config", + "codeowners", + "license", + "security-policy", + "dependabot", + "observability", + "area-readme", + "area-build-script", + "area-test-script" + ] + } +} diff --git a/examples/policies/repo-health-only.json b/examples/policies/repo-health-only.json new file mode 100644 index 0000000..ab02f12 --- /dev/null +++ b/examples/policies/repo-health-only.json @@ -0,0 +1,15 @@ +{ + "name": "repo-health-only", + "criteria": { + "disable": [ + "custom-instructions", + "mcp-config", + "custom-agents", + "copilot-skills", + "area-instructions" + ] + }, + "extras": { + "disable": ["agents-doc"] + } +} diff --git a/examples/policies/strict.json b/examples/policies/strict.json new file mode 100644 index 0000000..323968e --- /dev/null +++ b/examples/policies/strict.json @@ -0,0 +1,13 @@ +{ + "name": "strict", + "thresholds": { + "passRate": 1.0 + }, + "criteria": { + "override": { + "env-example": { "impact": "high" }, + "format-config": { "impact": "high" }, + "contributing": { "impact": "high" } + } + } +} diff --git a/examples/primer.eval.json b/examples/primer.eval.json new file mode 100644 index 0000000..279dbde --- /dev/null +++ b/examples/primer.eval.json @@ -0,0 +1,16 @@ +{ + "instructionFile": ".github/copilot-instructions.md", + "systemMessage": "You are answering questions about this repository. Use tools to inspect the repo and cite its files. Avoid generic Copilot CLI details unless the prompt explicitly asks for them.", + "cases": [ + { + "id": "project-overview", + "prompt": "Summarize what this project does and list the main entry points.", + "expectation": "Should mention the primary purpose and key files/directories." + }, + { + "id": "build-commands", + "prompt": "How do I build and test this project?", + "expectation": "Should provide the correct build and test commands from package.json or equivalent." + } + ] +} diff --git a/package-lock.json b/package-lock.json index 4679573..5c160f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,30 +7,46 @@ "": { "name": "primer", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { - "@github/copilot-sdk": "^0.1.19", - "@inquirer/prompts": "^8.2.0", + "@github/copilot-sdk": "^0.1.24", + "@inquirer/prompts": "^8.2.1", "@octokit/rest": "^22.0.1", "chalk": "^5.6.2", - "commander": "^14.0.2", + "commander": "^14.0.3", "fast-glob": "^3.3.3", - "ink": "^6.6.0", + "ink": "^6.7.0", "react": "^19.2.4", - "simple-git": "^3.30.0" + "simple-git": "^3.31.1" + }, + "bin": { + "primer": "dist/index.js" }, "devDependencies": { - "@types/node": "^25.1.0", - "@types/react": "^19.2.10", + "@types/node": "^25.2.3", + "@types/react": "^19.2.14", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9.39.2", + "eslint-config-prettier": "^9.1.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-n": "^17.24.0", + "eslint-plugin-promise": "^7.2.1", + "globals": "^17.3.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "prettier": "^3.8.1", "tsup": "^8.5.1", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" } }, "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz", - "integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", + "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -40,10 +56,70 @@ "node": ">=18" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -58,9 +134,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -75,9 +151,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -92,9 +168,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -109,9 +185,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -126,9 +202,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -143,9 +219,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -160,9 +236,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -177,9 +253,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -194,9 +270,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -211,9 +287,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -228,9 +304,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -245,9 +321,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -262,9 +338,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -279,9 +355,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -296,9 +372,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -313,9 +389,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -330,9 +406,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -347,9 +423,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -364,9 +440,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -381,9 +457,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -398,9 +474,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -415,9 +491,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -432,9 +508,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -449,9 +525,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -466,9 +542,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -482,27 +558,243 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@github/copilot": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.394.tgz", - "integrity": "sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", + "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.394", - "@github/copilot-darwin-x64": "0.0.394", - "@github/copilot-linux-arm64": "0.0.394", - "@github/copilot-linux-x64": "0.0.394", - "@github/copilot-win32-arm64": "0.0.394", - "@github/copilot-win32-x64": "0.0.394" + "@github/copilot-darwin-arm64": "0.0.411", + "@github/copilot-darwin-x64": "0.0.411", + "@github/copilot-linux-arm64": "0.0.411", + "@github/copilot-linux-x64": "0.0.411", + "@github/copilot-win32-arm64": "0.0.411", + "@github/copilot-win32-x64": "0.0.411" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.394.tgz", - "integrity": "sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", + "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", "cpu": [ "arm64" ], @@ -516,9 +808,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.394.tgz", - "integrity": "sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", + "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", "cpu": [ "x64" ], @@ -532,9 +824,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.394.tgz", - "integrity": "sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", + "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", "cpu": [ "arm64" ], @@ -548,9 +840,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.394.tgz", - "integrity": "sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", + "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", "cpu": [ "x64" ], @@ -564,23 +856,23 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.19.tgz", - "integrity": "sha512-h/KvYb6g99v9SurNJGxeXUatmP7GO8KHTAb68GYfmgUqH1EUeN5g0xMUc5lvKxAi7hwj2OxRR73dd37zMMiiiQ==", + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.25.tgz", + "integrity": "sha512-hIgYLPXzWw9bNgrsD5BLKmgVH20ow5Or5UyVXfVe3YgeiaTgFxC4jWSAVHLGB6ufHZUrvbjppcq2dWK63FmDRA==", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.394", + "@github/copilot": "^0.0.411", "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.5" + "zod": "^4.3.6" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.394.tgz", - "integrity": "sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", + "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", "cpu": [ "arm64" ], @@ -594,9 +886,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.394", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.394.tgz", - "integrity": "sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", + "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", "cpu": [ "x64" ], @@ -609,6 +901,58 @@ "copilot-win32-x64": "copilot.exe" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", @@ -619,13 +963,13 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.0.4.tgz", - "integrity": "sha512-DrAMU3YBGMUAp6ArwTIp/25CNDtDbxk7UjIrrtM25JVVrlVYlVzHh5HR1BDFu9JMyUoZ4ZanzeaHqNDttf3gVg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.0.tgz", + "integrity": "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, @@ -642,12 +986,12 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.4.tgz", - "integrity": "sha512-WdaPe7foUnoGYvXzH4jp4wH/3l+dBhZ3uwhKjXjwdrq5tEIFaANxj6zrGHxLdsIA0yKM0kFPVcEalOZXBB5ISA==", + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.8.tgz", + "integrity": "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "engines": { @@ -663,18 +1007,18 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.1.tgz", - "integrity": "sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", + "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3", "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^9.0.2" + "signal-exit": "^4.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -689,12 +1033,12 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.4.tgz", - "integrity": "sha512-QI3Jfqcv6UO2/VJaEFONH8Im1ll++Xn/AJTBn9Xf+qx2M+H8KZAdQ5sAe2vtYlo+mLW+d7JaMJB4qWtK4BG3pw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.8.tgz", + "integrity": "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/external-editor": "^2.0.3", "@inquirer/type": "^4.0.3" }, @@ -711,12 +1055,12 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.4.tgz", - "integrity": "sha512-0I/16YwPPP0Co7a5MsomlZLpch48NzYfToyqYAOWtBmaXSB80RiNQ1J+0xx2eG+Wfxt0nHtpEWSRr6CzNVnOGg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.8.tgz", + "integrity": "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "engines": { @@ -762,12 +1106,12 @@ } }, "node_modules/@inquirer/input": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.4.tgz", - "integrity": "sha512-4B3s3jvTREDFvXWit92Yc6jF1RJMDy2VpSqKtm4We2oVU65YOh2szY5/G14h4fHlyQdpUmazU5MPCFZPRJ0AOw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.8.tgz", + "integrity": "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "engines": { @@ -783,12 +1127,12 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.4.tgz", - "integrity": "sha512-CmMp9LF5HwE+G/xWsC333TlCzYYbXMkcADkKzcawh49fg2a1ryLc7JL1NJYYt1lJ+8f4slikNjJM9TEL/AljYQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.8.tgz", + "integrity": "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "engines": { @@ -804,13 +1148,13 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.4.tgz", - "integrity": "sha512-ZCEPyVYvHK4W4p2Gy6sTp9nqsdHQCfiPXIP9LbJVW4yCinnxL/dDDmPaEZVysGrj8vxVReRnpfS2fOeODe9zjg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.8.tgz", + "integrity": "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "engines": { @@ -826,21 +1170,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.2.0.tgz", - "integrity": "sha512-rqTzOprAj55a27jctS3vhvDDJzYXsr33WXTjODgVOru21NvBo9yIgLIAf7SBdSV0WERVly3dR6TWyp7ZHkvKFA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", + "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.0.4", - "@inquirer/confirm": "^6.0.4", - "@inquirer/editor": "^5.0.4", - "@inquirer/expand": "^5.0.4", - "@inquirer/input": "^5.0.4", - "@inquirer/number": "^4.0.4", - "@inquirer/password": "^5.0.4", - "@inquirer/rawlist": "^5.2.0", - "@inquirer/search": "^4.1.0", - "@inquirer/select": "^5.0.4" + "@inquirer/checkbox": "^5.1.0", + "@inquirer/confirm": "^6.0.8", + "@inquirer/editor": "^5.0.8", + "@inquirer/expand": "^5.0.8", + "@inquirer/input": "^5.0.8", + "@inquirer/number": "^4.0.8", + "@inquirer/password": "^5.0.8", + "@inquirer/rawlist": "^5.2.4", + "@inquirer/search": "^4.1.4", + "@inquirer/select": "^5.1.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -855,12 +1199,12 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.0.tgz", - "integrity": "sha512-CciqGoOUMrFo6HxvOtU5uL8fkjCmzyeB6fG7O1vdVAZVSopUBYECOwevDBlqNLyyYmzpm2Gsn/7nLrpruy9RFg==", + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.4.tgz", + "integrity": "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/type": "^4.0.3" }, "engines": { @@ -876,12 +1220,12 @@ } }, "node_modules/@inquirer/search": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.0.tgz", - "integrity": "sha512-EAzemfiP4IFvIuWnrHpgZs9lAhWDA0GM3l9F4t4mTQ22IFtzfrk8xbkMLcAN7gmVML9O/i+Hzu8yOUyAaL6BKA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", + "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, @@ -898,13 +1242,13 @@ } }, "node_modules/@inquirer/select": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.0.4.tgz", - "integrity": "sha512-s8KoGpPYMEQ6WXc0dT9blX2NtIulMdLOO3LA1UKOiv7KFWzlJ6eLkEYTDBIi+JkyKXyn8t/CD6TinxGjyLt57g==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", + "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.1", + "@inquirer/core": "^11.1.5", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, @@ -1054,9 +1398,9 @@ } }, "node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", @@ -1129,15 +1473,16 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", "license": "MIT", "dependencies": { - "@octokit/endpoint": "^11.0.2", + "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1181,9 +1526,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1195,9 +1540,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1209,9 +1554,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1223,9 +1568,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1237,9 +1582,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1251,9 +1596,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1265,9 +1610,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1279,9 +1624,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1293,9 +1638,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1307,9 +1652,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1321,9 +1666,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -1335,9 +1680,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1349,9 +1694,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1363,9 +1708,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1377,9 +1722,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1391,9 +1736,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1405,9 +1750,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1419,9 +1764,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1433,9 +1778,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1447,9 +1792,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1461,9 +1806,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1475,9 +1820,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1489,9 +1834,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1503,9 +1848,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1517,9 +1862,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1530,6 +1875,38 @@ "win32" ] }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1537,325 +1914,463 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { - "version": "19.2.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", - "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=0.4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, "license": "MIT", "dependencies": { - "environment": "^1.0.0" + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, - "license": "MIT" - }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "license": "Apache-2.0" - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=8" + "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.0.0" } }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "esbuild": ">=0.18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, "engines": { - "node": ">=8" + "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.0.0" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://paulmillr.com/funding/" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", + "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": ">=10" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" - }, - "engines": { - "node": ">=20" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, "license": "MIT", "dependencies": { - "convert-to-spaces": "^2.0.1" + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=20" + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=0.4.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, "engines": { "node": ">=18" }, @@ -1863,1172 +2378,5182 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" + "node": ">=12" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { - "node": ">=8.6.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", "dependencies": { - "reusify": "^1.0.4" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=12" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.4" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, + "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": ">= 6" + "node": "18 || 20 || >=22" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": "18 || 20 || >=22" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "fill-range": "^7.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "node_modules/ink": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", - "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, "license": "MIT", "dependencies": { - "@alcalzone/ansi-tokenize": "^0.2.1", - "ansi-escapes": "^7.2.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.6.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^5.1.1", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.39.10", - "indent-string": "^5.0.0", - "is-in-ci": "^2.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.33.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^8.1.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" + "load-tsconfig": "^0.2.3" }, "engines": { - "node": ">=20" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "peerDependencies": { - "@types/react": ">=19.0.0", - "react": ">=19.0.0", - "react-devtools-core": "^6.1.2" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } + "esbuild": ">=0.18" } }, - "node_modules/ink/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/ink/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" }, "engines": { - "node": ">=20" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", - "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { - "node": ">=0.12.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, "engines": { - "node": ">=10" + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, "engines": { - "node": ">= 8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=8.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 12" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=20" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", "dev": true, "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "engines": { + "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" }, "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, "engines": { - "node": ">= 6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "lilconfig": "^3.1.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10.13.0" } }, - "node_modules/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=18" }, - "peerDependencies": { - "react": "^19.2.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 14.18.0" + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "engines": { + "node": ">= 0.4" } }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "es-errors": "^1.3.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/reusify": { + "node_modules/es-shim-unscopables": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", "bin": { - "rollup": "dist/bin/rollup" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=18" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", - "fsevents": "~2.3.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=14" + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-n": { + "version": "17.24.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.24.0.tgz", + "integrity": "sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "globrex": "^0.1.2", + "ignore": "^5.3.2", + "semver": "^7.6.3", + "ts-declaration-location": "^1.0.6" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.23.0" } }, - "node_modules/simple-git": { - "version": "3.30.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.30.0.tgz", - "integrity": "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==", + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, "license": "MIT", - "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.4.0" + "engines": { + "node": ">=18" }, "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "node_modules/eslint-plugin-n/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-promise": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz", + "integrity": "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA==", + "dev": true, + "license": "ISC", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "@eslint-community/eslint-utils": "^4.4.0" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-3-Clause", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">= 12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { - "escape-string-regexp": "^2.0.0" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=18" + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz", + "integrity": "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.4", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^8.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.1", + "terminal-size": "^4.0.1", + "type-fest": "^5.4.1", + "widest-line": "^6.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.3.tgz", + "integrity": "sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-git": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.32.2.tgz", + "integrity": "sha512-n/jhNmvYh8dwyfR6idSfpXrFazuyd57jwNMzgjGnKZV/1lTh0HKvPq20v4AQ62rP+l19bWjjXPTCdGHMt0AdrQ==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-declaration-location": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", + "integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==", + "dev": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": ">=4.0.0" + } + }, + "node_modules/ts-declaration-location/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_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/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=18" } }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", "license": "MIT", "engines": { - "node": ">= 6" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" + "node": ">=14.0.0" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "thenify": ">= 3.1.0 < 4" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">=0.8" + "node": ">= 8" } }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" }, - "engines": { - "node": ">=8.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", - "bin": { - "tree-kill": "cli.js" + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "siginfo": "^2.0.0", + "stackback": "0.0.2" }, "bin": { - "tsx": "dist/cli.mjs" + "why-is-node-running": "cli.js" }, "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": ">=8" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/universal-user-agent": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", - "license": "ISC" - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=0.10.0" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { - "string-width": "^7.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ws": { @@ -3052,6 +7577,35 @@ } } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", diff --git a/package.json b/package.json index 2a15771..4923e6a 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,62 @@ { "name": "primer", - "version": "1.0.0", - "description": "", - "main": "index.js", + "version": "2.0.0", + "description": "Prime repositories for AI-assisted development", + "main": "dist/index.js", + "type": "module", + "bin": { + "primer": "dist/index.js" + }, + "files": [ + "dist" + ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsup", + "dev": "tsx src/index.ts", + "prepare": "npm run build && (test \"$CI\" = true || husky)", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "dependencies": { - "@github/copilot-sdk": "^0.1.19", - "@inquirer/prompts": "^8.2.0", + "@github/copilot-sdk": "^0.1.24", + "@inquirer/prompts": "^8.2.1", "@octokit/rest": "^22.0.1", "chalk": "^5.6.2", - "commander": "^14.0.2", + "commander": "^14.0.3", "fast-glob": "^3.3.3", - "ink": "^6.6.0", + "ink": "^6.7.0", "react": "^19.2.4", - "simple-git": "^3.30.0" + "simple-git": "^3.31.1" + }, + "lint-staged": { + "*.{ts,tsx,js,json,md}": "prettier --write" }, "devDependencies": { - "@types/node": "^25.1.0", - "@types/react": "^19.2.10", + "@types/node": "^25.2.3", + "@types/react": "^19.2.14", + "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/parser": "^8.56.0", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9.39.2", + "eslint-config-prettier": "^9.1.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-n": "^17.24.0", + "eslint-plugin-promise": "^7.2.1", + "globals": "^17.3.0", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "prettier": "^3.8.1", "tsup": "^8.5.1", "tsx": "^4.21.0", - "typescript": "^5.9.3" - }, - "type": "module", - "bin": { - "primer": "dist/index.js" + "typescript": "^5.9.3", + "vitest": "^4.0.18" } } diff --git a/primer.eval.json b/primer.eval.json index 3b77578..0544a38 100644 --- a/primer.eval.json +++ b/primer.eval.json @@ -1,10 +1,36 @@ { "instructionFile": ".github/copilot-instructions.md", + "systemMessage": "You are answering questions about the Primer repository. Scope your answers to this repo's architecture, usage, configuration, and workflows. Do not provide generic Copilot CLI details unless specifically asked.", "cases": [ { - "id": "overview", - "prompt": "Summarize what this application does and list the main entrypoints.", - "expectation": "Mentions the CLI entrypoint in src/index.ts and that this is the Primer CLI." + "id": "case-1", + "prompt": "What is Primer's architecture and how are its layers organized?", + "expectation": "Primer is a TypeScript CLI tool for priming repositories for AI-assisted development. It follows a layered architecture: src/index.ts is the entrypoint which defaults to the interactive TUI when no command is given, otherwise delegates to runCli in src/cli.ts. Commander wires subcommands (in src/commands/) to service functions (in src/services/), with shared utilities in src/utils/ and Ink/React TUI components in src/ui/. The CLI layer handles option parsing and output formatting, services contain all core logic (analyzer, instructions, readiness, evaluator, batch, git, github, azureDevops, etc.), and utils provide cross-cutting concerns like safe file I/O, structured output, and working directory management." + }, + { + "id": "case-2", + "prompt": "What is the local development workflow and how does building for distribution differ?", + "expectation": "For local development, run commands directly with npx tsx src/index.ts (or npm run dev) — tsx executes TypeScript without a build step. Linting uses eslint (npm run lint), formatting uses prettier (npm run format / format:check), type checking uses tsc --noEmit (npm run typecheck), and tests run with vitest using v8 coverage (npm run test / test:coverage). Husky and lint-staged enforce linting on pre-commit. For distribution, tsup bundles src/index.ts into ESM-only output targeting Node 20+, with a shebang banner, sourcemaps, and external dependencies not bundled." + }, + { + "id": "case-3", + "prompt": "What patterns and conventions should I follow when adding new functionality to this codebase?", + "expectation": "Place new CLI commands in src/commands/, core logic in src/services/, and TUI components in src/ui/. All commands must support --json and --quiet flags via the withGlobalOpts wrapper in cli.ts, and return structured results using the CommandResult type from utils/output.ts. Use outputResult() for dual JSON/human output and shouldLog() to gate stderr progress. File writes must use safeWriteFile() which prevents accidental overwrites unless --force is passed. ESM syntax is required everywhere, TypeScript is strict (ES2022 target, ESNext module). Area-specific instructions go in .github/instructions/{name}.instructions.md with YAML frontmatter. The default model for Copilot SDK operations is claude-sonnet-4.5." + }, + { + "id": "case-4", + "prompt": "How does the AI readiness assessment work, and how can it be customized with policies?", + "expectation": "The readiness service in src/services/readiness.ts evaluates repositories across 9 pillars (style-validation, build-system, testing, documentation, dev-environment, code-quality, observability, security, ai-tooling) and assigns a maturity level from 1 (Functional) to 5 (Autonomous). Each criterion has a scope — repo, app, or area — determining whether it runs once, per monorepo app, or per detected area. buildCriteria() returns 20+ built-in checks and buildExtras() adds optional ones. Policies loaded via src/services/policy.ts can customize the assessment: loadPolicy() reads JSON/TS/JS configs, and resolveChain() merges a chain of policies that can disable, override, or add criteria and set pass-rate thresholds. Results can be rendered as an interactive HTML report by src/services/visualReport.ts with dark/light theme toggle and expandable per-pillar details." + }, + { + "id": "case-5", + "prompt": "How does Primer generate Copilot instructions, including for monorepos with multiple areas?", + "expectation": "The instruction generation pipeline starts with the analyzer (src/services/analyzer.ts) which scans the repo to detect languages, frameworks, monorepo apps, and logical areas (frontend, backend, etc.) with glob patterns. For root-level instructions, generateCopilotInstructions() in src/services/instructions.ts creates a Copilot SDK session that explores the codebase using tools (glob, view, grep) and produces .github/copilot-instructions.md. For area-specific instructions, generateAreaInstructions() generates focused content per area, and buildAreaFrontmatter() creates YAML frontmatter with applyTo glob patterns so VS Code scopes them to the right files. These are written to .github/instructions/{sanitized-name}.instructions.md via writeAreaInstruction(). The instructions command supports --areas to generate all area instructions, --areas-only to skip the root file, and --area for a single area." + }, + { + "id": "case-6", + "prompt": "What safety and security patterns does the codebase use for file operations and CLI output?", + "expectation": "For file safety, src/utils/fs.ts provides safeWriteFile() which checks for existing files and only overwrites with an explicit force flag, validateCachePath() which rejects paths containing .. or symlinks to prevent path traversal, and fileExists() with symlink rejection. The repo.ts validators use regexes (GITHUB_REPO_RE, AZURE_REPO_RE) that reject traversal patterns in repo identifiers. For credential safety, git.ts and batch.ts use sanitizeError() to strip tokens from error messages before surfacing them. For structured output, utils/output.ts defines the CommandResult type with ok/status/data fields, outputResult() writes JSON to stdout or human text to stderr based on --json/--quiet flags, and shouldLog() gates progress output. This dual-mode pattern ensures all commands work both interactively and in headless automation pipelines." } ] } diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..0ef4665 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "release-type": "node", + "packages": { + ".": { + "package-name": "primer", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/release-please-manifest.json b/release-please-manifest.json new file mode 100644 index 0000000..895bf0e --- /dev/null +++ b/release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.0.0" +} diff --git a/src/cli.ts b/src/cli.ts index 86bcf74..d5e8ce6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,15 +1,34 @@ -import { Command } from "commander"; -import { initCommand } from "./commands/init"; +import { Argument, Command } from "commander"; + import { analyzeCommand } from "./commands/analyze"; +import { batchCommand } from "./commands/batch"; +import { batchReadinessCommand } from "./commands/batchReadiness"; +import { evalCommand } from "./commands/eval"; import { generateCommand } from "./commands/generate"; +import { initCommand } from "./commands/init"; +import { instructionsCommand } from "./commands/instructions"; import { prCommand } from "./commands/pr"; -import { templatesCommand } from "./commands/templates"; -import { updateCommand } from "./commands/update"; -import { configCommand } from "./commands/config"; -import { evalCommand } from "./commands/eval"; +import { readinessCommand } from "./commands/readiness"; import { tuiCommand } from "./commands/tui"; -import { instructionsCommand } from "./commands/instructions"; -import { batchCommand } from "./commands/batch"; +import { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "./config"; + +/** + * Merge program-level --json/--quiet into each command's local options + * so every action handler receives a unified options object. + */ +export function withGlobalOpts>( + fn: (...args: [...TArgs, TOptions]) => Promise +): (...raw: unknown[]) => Promise { + return async (...raw: unknown[]) => { + const cmd = raw[raw.length - 1] as Command; + const localOpts = raw[raw.length - 2] as TOptions; + const globalOpts = cmd.optsWithGlobals(); + const merged = { ...localOpts, json: globalOpts.json, quiet: globalOpts.quiet } as TOptions; + raw[raw.length - 2] = merged; + raw.pop(); // remove Command + await (fn as (...args: unknown[]) => Promise)(...raw); + }; +} export function runCli(argv: string[]): void { const program = new Command(); @@ -17,67 +36,116 @@ export function runCli(argv: string[]): void { program .name("primer") .description("Prime repositories for AI-assisted development") - .version("0.1.0"); + .version("1.0.0") + .option("--json", "Output machine-readable JSON to stdout") + .option("--quiet", "Suppress stderr progress output"); program .command("init") + .description("Interactive repo setup — analyze, generate instructions and configs") .argument("[path]", "Path to a local repository") .option("--github", "Use a GitHub repository") - .option("--yes", "Accept defaults and skip prompts") + .option("--provider ", "Repo provider (github|azure)") + .option("--yes", "Accept defaults (generates instructions, MCP, and VS Code configs)") .option("--force", "Overwrite existing files") - .action(initCommand); + .option("--model ", "Model for instructions generation", DEFAULT_MODEL) + .action(withGlobalOpts(initCommand)); program .command("analyze") + .description("Detect languages, frameworks, monorepo structure, and areas") .argument("[path]", "Path to a local repository") - .option("--json", "Output JSON") - .action(analyzeCommand); + .option("--output ", "Write report to file (.json or .md)") + .option("--force", "Overwrite existing output file") + .action(withGlobalOpts(analyzeCommand)); program .command("generate") - .argument("", "prompts|agents|mcp|vscode|aiignore") + .description("Generate instructions, agents, MCP, or VS Code configs") + .addArgument( + new Argument("", "Config type to generate").choices([ + "instructions", + "agents", + "mcp", + "vscode" + ]) + ) .argument("[path]", "Path to a local repository") .option("--force", "Overwrite existing files") - .action(generateCommand); + .option("--per-app", "Generate per-app in monorepos") + .option("--model ", "Model for instructions generation", DEFAULT_MODEL) + .action(withGlobalOpts(generateCommand)); program .command("pr") - .argument("[repo]", "GitHub repo in owner/name form") - .option("--branch ", "Branch name", "primer/add-configs") - .action(prCommand); + .description("Create a PR with generated configs on GitHub or Azure DevOps") + .argument("[repo]", "Repo identifier (github: owner/name, azure: org/project/repo)") + .option("--branch ", "Branch name") + .option("--provider ", "Repo provider (github|azure)") + .option("--model ", "Model for instructions generation", DEFAULT_MODEL) + .action(withGlobalOpts(prCommand)); program .command("eval") + .description("Compare AI responses with and without instructions") .argument("[path]", "Path to eval config JSON") .option("--repo ", "Repository path", process.cwd()) - .option("--model ", "Model for responses", "gpt-5") - .option("--judge-model ", "Model for judging", "gpt-5") + .option("--model ", "Model for responses", DEFAULT_MODEL) + .option("--judge-model ", "Model for judging", DEFAULT_JUDGE_MODEL) + .option("--list-models", "List Copilot CLI models and exit") .option("--output ", "Write results JSON to file") .option("--init", "Create a starter primer.eval.json file") - .action(evalCommand); + .option("--count ", "Number of eval cases to generate (with --init)") + .option("--fail-level ", "Exit with error if pass rate (%) falls below threshold") + .action(withGlobalOpts(evalCommand)); program .command("tui") + .description("Interactive terminal UI for generation, evaluation, and batch workflows") .option("--repo ", "Repository path", process.cwd()) .option("--no-animation", "Skip the animated banner intro") - .action(tuiCommand); + .action(withGlobalOpts(tuiCommand)); program .command("instructions") + .description("Generate root and per-area .instructions.md files") .option("--repo ", "Repository path", process.cwd()) .option("--output ", "Output path for copilot instructions") - .option("--model ", "Model for instructions generation", "gpt-4.1") - .action(instructionsCommand); + .option("--model ", "Model for instructions generation", DEFAULT_MODEL) + .option("--force", "Overwrite existing area instruction files") + .option("--areas", "Also generate file-based instructions for detected areas") + .option("--areas-only", "Generate only file-based area instructions (skip root)") + .option("--area ", "Generate file-based instructions for a specific area") + .action(withGlobalOpts(instructionsCommand)); + + program + .command("readiness") + .description("AI readiness assessment across 9 maturity pillars") + .argument("[path]", "Path to a local repository") + .option("--output ", "Write report to file (.json, .md, or .html)") + .option("--force", "Overwrite existing output file") + .option("--visual", "Generate visual HTML report") + .option("--per-area", "Show per-area readiness breakdown") + .option("--policy ", "Policy sources (comma-separated: paths, npm packages)") + .option("--fail-level ", "Exit with error if readiness level is below threshold (1–5)") + .action(withGlobalOpts(readinessCommand)); program .command("batch") .description("Batch process multiple repos across orgs") + .argument("[repos...]", "Repos in owner/name form (GitHub) or org/project/repo (Azure)") .option("--output ", "Write results JSON to file") - .action(batchCommand); + .option("--provider ", "Repo provider (github|azure)", "github") + .option("--model ", "Model for instructions generation", DEFAULT_MODEL) + .option("--branch ", "Branch name for PRs") + .action(withGlobalOpts(batchCommand)); - program.command("templates").action(templatesCommand); - program.command("update").action(updateCommand); - program.command("config").action(configCommand); + program + .command("batch-readiness") + .description("Generate batch AI readiness report for multiple repos") + .option("--output ", "Write HTML report to file") + .option("--policy ", "Policy sources (comma-separated: paths, npm packages)") + .action(withGlobalOpts(batchReadinessCommand)); program.parse(argv); } diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 10aeea0..242c530 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -1,23 +1,131 @@ import path from "path"; + +import chalk from "chalk"; + +import type { RepoAnalysis } from "../services/analyzer"; import { analyzeRepo } from "../services/analyzer"; +import { safeWriteFile } from "../utils/fs"; +import { prettyPrintSummary } from "../utils/logger"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, shouldLog } from "../utils/output"; type AnalyzeOptions = { json?: boolean; + quiet?: boolean; + output?: string; + force?: boolean; }; -export async function analyzeCommand(repoPathArg: string | undefined, options: AnalyzeOptions): Promise { +export async function analyzeCommand( + repoPathArg: string | undefined, + options: AnalyzeOptions +): Promise { const repoPath = path.resolve(repoPathArg ?? process.cwd()); - const analysis = await analyzeRepo(repoPath); - if (options.json) { - console.log(JSON.stringify(analysis, null, 2)); - return; + try { + const analysis = await analyzeRepo(repoPath); + + // Write to file when --output is specified + if (options.output) { + const outputPath = path.resolve(options.output); + const ext = path.extname(outputPath).toLowerCase(); + if (ext !== ".json" && ext !== ".md") { + outputError( + `Unsupported output format: ${ext || "(no extension)"}. Use .json or .md`, + Boolean(options.json) + ); + return; + } + const content = + ext === ".md" ? formatAnalysisMarkdown(analysis) : JSON.stringify(analysis, null, 2); + + const { wrote, reason } = await safeWriteFile(outputPath, content, Boolean(options.force)); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + outputError(`Skipped ${outputPath}: ${why}`, Boolean(options.json)); + return; + } + if (options.json) { + const result: CommandResult = { + ok: true, + status: "success", + data: analysis + }; + outputResult(result, true); + return; + } + if (shouldLog(options)) { + process.stderr.write(chalk.green(`✓ Report saved: ${outputPath}`) + "\n"); + } + return; + } + + if (options.json) { + const result: CommandResult = { + ok: true, + status: "success", + data: analysis + }; + outputResult(result, true); + } else if (shouldLog(options)) { + prettyPrintSummary(analysis); + } + } catch (error) { + outputError(error instanceof Error ? error.message : String(error), Boolean(options.json)); + } +} + +export function formatAnalysisMarkdown(analysis: RepoAnalysis): string { + const lines: string[] = []; + const repoName = path.basename(analysis.path); + + lines.push(`# Repository Analysis: ${repoName}`); + lines.push(""); + lines.push("## Overview"); + lines.push(""); + lines.push(`| Property | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Path | \`${analysis.path}\` |`); + lines.push(`| Git repository | ${analysis.isGitRepo ? "Yes" : "No"} |`); + lines.push(`| Languages | ${analysis.languages.join(", ") || "Unknown"} |`); + lines.push(`| Frameworks | ${analysis.frameworks.join(", ") || "None detected"} |`); + lines.push(`| Package manager | ${analysis.packageManager ?? "Unknown"} |`); + if (analysis.isMonorepo) { + lines.push( + `| Monorepo | ${analysis.workspaceType ?? "yes"} (${analysis.apps?.length ?? 0} apps) |` + ); + } + + if (analysis.apps && analysis.apps.length > 0) { + lines.push(""); + lines.push("## Applications"); + lines.push(""); + lines.push("| Name | Ecosystem | TypeScript | Path |"); + lines.push("| --- | --- | --- | --- |"); + for (const app of analysis.apps) { + const rel = path.relative(analysis.path, app.path).replace(/\\/gu, "/") || "."; + lines.push( + `| ${app.name} | ${app.ecosystem ?? "—"} | ${app.hasTsConfig ? "Yes" : "No"} | \`${rel}\` |` + ); + } } - console.log("Repository analysis:"); - console.log(`- Path: ${analysis.path}`); - console.log(`- Git: ${analysis.isGitRepo ? "yes" : "no"}`); - console.log(`- Languages: ${analysis.languages.join(", ") || "unknown"}`); - console.log(`- Frameworks: ${analysis.frameworks.join(", ") || "none"}`); - console.log(`- Package manager: ${analysis.packageManager ?? "unknown"}`); + if (analysis.areas && analysis.areas.length > 0) { + lines.push(""); + lines.push("## Areas"); + lines.push(""); + lines.push("| Name | Source | Pattern |"); + lines.push("| --- | --- | --- |"); + for (const area of analysis.areas) { + const pattern = Array.isArray(area.applyTo) ? area.applyTo.join(", ") : area.applyTo; + lines.push(`| ${area.name} | ${area.source} | \`${pattern}\` |`); + } + } + + lines.push(""); + lines.push(`---`); + lines.push(`*Generated by [Primer](https://github.com/pierceboggan/primer)*`); + lines.push(""); + + return lines.join("\n"); } diff --git a/src/commands/batch.tsx b/src/commands/batch.tsx index eb69267..d9947a2 100644 --- a/src/commands/batch.tsx +++ b/src/commands/batch.tsx @@ -1,31 +1,240 @@ -import React from "react"; +import readline from "readline"; + import { render } from "ink"; +import React from "react"; + +import type { AzureDevOpsRepo } from "../services/azureDevops"; +import { getAzureDevOpsToken, getRepo as getAzureRepo } from "../services/azureDevops"; +import { runBatchHeadlessGitHub, runBatchHeadlessAzure, sanitizeError } from "../services/batch"; +import type { ProcessResult } from "../services/batch"; +import type { GitHubRepo } from "../services/github"; +import { getGitHubToken, getRepo as getGitHubRepo } from "../services/github"; import { BatchTui } from "../ui/BatchTui"; -import { getGitHubToken } from "../services/github"; +import { BatchTuiAzure } from "../ui/BatchTuiAzure"; +import { safeWriteFile } from "../utils/fs"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; +import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../utils/repo"; type BatchOptions = { output?: string; + provider?: string; + model?: string; + branch?: string; + json?: boolean; + quiet?: boolean; }; -export async function batchCommand(options: BatchOptions): Promise { +export async function batchCommand(repos: string[], options: BatchOptions): Promise { + const provider = options.provider ?? "github"; + if (provider !== "github" && provider !== "azure") { + outputError("Invalid provider. Use github or azure.", Boolean(options.json)); + return; + } + + // Read repos from stdin if piped + const stdinRepos = await readStdinRepos(); + if (stdinRepos.length > 0 && repos.length > 0) { + outputError( + "Provide repos via positional arguments OR stdin, not both.", + Boolean(options.json) + ); + return; + } + + const allRepoArgs = repos.length > 0 ? repos : stdinRepos; + const isHeadless = allRepoArgs.length > 0 || Boolean(options.json); + + if (isHeadless) { + if (allRepoArgs.length === 0) { + outputError( + "No repos provided. Pass repos as arguments or pipe via stdin.", + Boolean(options.json) + ); + return; + } + await runHeadless(allRepoArgs, provider, options); + return; + } + + // Interactive TUI mode + if (provider === "azure") { + const token = getAzureDevOpsToken(); + if (!token) { + outputError( + "Azure DevOps authentication required. Set AZURE_DEVOPS_PAT (or AZDO_PAT).", + Boolean(options.json) + ); + return; + } + + try { + const { waitUntilExit } = render(); + await waitUntilExit(); + } catch (error) { + outputError( + `TUI failed: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + } + return; + } + const token = await getGitHubToken(); - if (!token) { - console.error("Error: GitHub authentication required."); - console.error(""); - console.error("Option 1 (recommended): Install and authenticate GitHub CLI"); - console.error(" brew install gh && gh auth login"); - console.error(""); - console.error("Option 2: Set a token environment variable"); - console.error(" export GITHUB_TOKEN="); - process.exitCode = 1; + outputError( + "GitHub authentication required. Install and authenticate GitHub CLI (gh auth login) or set GITHUB_TOKEN.", + Boolean(options.json) + ); return; } - const { waitUntilExit } = render( - - ); + try { + const { waitUntilExit } = render(); + await waitUntilExit(); + } catch (error) { + outputError( + `TUI failed: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + } +} + +// ── Headless implementation ── + +async function runHeadless( + repoArgs: string[], + provider: string, + options: BatchOptions +): Promise { + const progress = createProgressReporter(!shouldLog(options)); + + if (provider === "azure") { + const token = getAzureDevOpsToken(); + if (!token) { + outputError( + "Set AZURE_DEVOPS_PAT (or AZDO_PAT) to use Azure DevOps batch automation.", + Boolean(options.json) + ); + return; + } + + const repos: AzureDevOpsRepo[] = []; + for (const arg of repoArgs) { + const match = arg.match(AZURE_REPO_RE); + if (!match) { + outputError( + `Invalid Azure DevOps repo format: "${arg}". Use org/project/repo.`, + Boolean(options.json) + ); + return; + } + const [, org, project, name] = match; + progress.update(`Fetching ${arg}...`); + try { + const repo = await getAzureRepo(token, org, project, name); + repos.push(repo); + } catch (error) { + outputError( + `Failed to fetch repo ${arg}: ${sanitizeError(error instanceof Error ? error.message : String(error))}`, + Boolean(options.json) + ); + return; + } + } + + const results = await runBatchHeadlessAzure(repos, token, progress, { + model: options.model, + branch: options.branch + }); + await emitResults(results, options); + return; + } + + // GitHub provider + const token = await getGitHubToken(); + if (!token) { + outputError( + "Set GITHUB_TOKEN or GH_TOKEN, or authenticate with GitHub CLI.", + Boolean(options.json) + ); + return; + } + + const repos: GitHubRepo[] = []; + for (const arg of repoArgs) { + const match = arg.match(GITHUB_REPO_RE); + if (!match) { + outputError(`Invalid GitHub repo format: "${arg}". Use owner/name.`, Boolean(options.json)); + return; + } + const [, owner, name] = match; + progress.update(`Fetching ${arg}...`); + try { + const repo = await getGitHubRepo(token, owner, name); + repos.push(repo); + } catch (error) { + outputError( + `Failed to fetch repo ${arg}: ${sanitizeError(error instanceof Error ? error.message : String(error))}`, + Boolean(options.json) + ); + return; + } + } + + const results = await runBatchHeadlessGitHub(repos, token, progress, { + model: options.model, + branch: options.branch + }); + await emitResults(results, options); +} + +async function emitResults(results: ProcessResult[], options: BatchOptions): Promise { + const succeeded = results.filter((r) => r.success).length; + const failed = results.length - succeeded; + + if (options.output) { + await safeWriteFile(options.output, JSON.stringify(results, null, 2), true); + } + + if (options.json) { + const result: CommandResult<{ results: ProcessResult[]; succeeded: number; failed: number }> = { + ok: failed === 0, + status: failed === 0 ? "success" : succeeded > 0 ? "partial" : "error", + data: { results, succeeded, failed } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + process.stderr.write(`\nBatch complete: ${succeeded} succeeded, ${failed} failed\n`); + for (const r of results) { + if (r.success) { + process.stderr.write(` ✓ ${r.repo}${r.prUrl ? ` → ${r.prUrl}` : ""}\n`); + } else { + process.stderr.write(` ✗ ${r.repo} (${r.error})\n`); + } + } + } + + if (failed > 0) { + process.exitCode = 1; + } +} + +// ── Stdin reader ── +async function readStdinRepos(): Promise { + if (process.stdin.isTTY) return []; - await waitUntilExit(); + return new Promise((resolve) => { + const repos: string[] = []; + const rl = readline.createInterface({ input: process.stdin }); + rl.on("line", (line) => { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + repos.push(trimmed); + } + }); + rl.on("close", () => resolve(repos)); + rl.on("error", () => resolve(repos)); + }); } diff --git a/src/commands/batchReadiness.tsx b/src/commands/batchReadiness.tsx new file mode 100644 index 0000000..697849a --- /dev/null +++ b/src/commands/batchReadiness.tsx @@ -0,0 +1,38 @@ +import { render } from "ink"; +import React from "react"; + +import { getGitHubToken } from "../services/github"; +import { parsePolicySources } from "../services/policy"; +import { BatchReadinessTui } from "../ui/BatchReadinessTui"; +import { outputError } from "../utils/output"; + +type BatchReadinessOptions = { + output?: string; + policy?: string; + json?: boolean; + quiet?: boolean; +}; + +export async function batchReadinessCommand(options: BatchReadinessOptions): Promise { + const token = await getGitHubToken(); + if (!token) { + outputError( + "GitHub authentication required. Install and authenticate GitHub CLI (gh auth login) or set GITHUB_TOKEN.", + Boolean(options.json) + ); + return; + } + + try { + const policies = parsePolicySources(options.policy); + const { waitUntilExit } = render( + + ); + await waitUntilExit(); + } catch (error) { + outputError( + `TUI failed: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + } +} diff --git a/src/commands/config.ts b/src/commands/config.ts deleted file mode 100644 index a924966..0000000 --- a/src/commands/config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function configCommand(): Promise { - console.log("Config is not implemented yet."); -} diff --git a/src/commands/eval.ts b/src/commands/eval.ts index b875f97..ad56f3b 100644 --- a/src/commands/eval.ts +++ b/src/commands/eval.ts @@ -1,6 +1,12 @@ import path from "path"; -import fs from "fs/promises"; + +import { DEFAULT_MODEL, DEFAULT_JUDGE_MODEL } from "../config"; +import { listCopilotModels } from "../services/copilot"; +import { generateEvalScaffold } from "../services/evalScaffold"; import { runEval } from "../services/evaluator"; +import { safeWriteFile } from "../utils/fs"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; type EvalOptions = { repo?: string; @@ -8,58 +14,143 @@ type EvalOptions = { judgeModel?: string; output?: string; init?: boolean; + count?: string; + listModels?: boolean; + failLevel?: string; + json?: boolean; + quiet?: boolean; }; -const EVAL_SCAFFOLD = { - instructionFile: ".github/copilot-instructions.md", - cases: [ - { - id: "project-overview", - prompt: "Summarize what this project does and list the main entry points.", - expectation: "Should mention the primary purpose and key files/directories." - }, - { - id: "tech-stack", - prompt: "What languages and frameworks does this project use?", - expectation: "Should correctly identify the main languages and frameworks." - }, - { - id: "build-commands", - prompt: "How do I build and test this project?", - expectation: "Should provide the correct build and test commands from package.json or equivalent." +export async function evalCommand( + configPathArg: string | undefined, + options: EvalOptions +): Promise { + const repoPath = path.resolve(options.repo ?? process.cwd()); + + if (options.listModels) { + try { + const models = await listCopilotModels(); + if (!models.length) { + if (options.json) { + const result: CommandResult<{ models: string[] }> = { + ok: true, + status: "success", + data: { models: [] } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + process.stderr.write("No models detected from Copilot CLI.\n"); + } + return; + } + if (options.json) { + const result: CommandResult<{ models: string[] }> = { + ok: true, + status: "success", + data: { models } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + process.stderr.write(models.join("\n") + "\n"); + } + } catch (error) { + outputError( + `Failed to list models: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); } - ] -}; + return; + } -export async function evalCommand(configPathArg: string | undefined, options: EvalOptions): Promise { - const repoPath = path.resolve(options.repo ?? process.cwd()); - // Handle --init flag if (options.init) { const outputPath = path.join(repoPath, "primer.eval.json"); + const desiredCount = Math.max(1, Number.parseInt(options.count ?? "5", 10) || 5); try { - await fs.access(outputPath); - console.error(`primer.eval.json already exists at ${outputPath}`); - process.exitCode = 1; - return; - } catch { - // File doesn't exist, create it + const progress = createProgressReporter(!shouldLog(options)); + const scaffold = await generateEvalScaffold({ + repoPath, + count: desiredCount, + model: options.model, + onProgress: (msg) => progress.update(msg) + }); + const { wrote, reason } = await safeWriteFile( + outputPath, + JSON.stringify(scaffold, null, 2), + false + ); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists"; + outputError(`Skipped ${outputPath}: ${why}`, Boolean(options.json)); + return; + } + + if (options.json) { + const result: CommandResult<{ outputPath: string }> = { + ok: true, + status: "success", + data: { outputPath } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + process.stderr.write(`Created ${outputPath}\n`); + process.stderr.write( + "Edit the file to add your own test cases, then run 'primer eval' to test.\n" + ); + } + } catch (error) { + outputError( + `Failed to scaffold eval config: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); } - await fs.writeFile(outputPath, JSON.stringify(EVAL_SCAFFOLD, null, 2), "utf8"); - console.log(`Created ${outputPath}`); - console.log("Edit the file to add your own test cases, then run 'primer eval' to test."); return; } const configPath = path.resolve(configPathArg ?? path.join(repoPath, "primer.eval.json")); - const { summary } = await runEval({ - configPath, - repoPath, - model: options.model ?? "gpt-5", - judgeModel: options.judgeModel ?? "gpt-5", - outputPath: options.output - }); + try { + const progress = createProgressReporter(!shouldLog(options)); + const { summary, results, viewerPath } = await runEval({ + configPath, + repoPath, + model: options.model ?? DEFAULT_MODEL, + judgeModel: options.judgeModel ?? DEFAULT_JUDGE_MODEL, + outputPath: options.output, + onProgress: (msg) => progress.update(msg) + }); + + if (options.json) { + const result: CommandResult<{ summary: string; viewerPath?: string }> = { + ok: true, + status: "success", + data: { summary, viewerPath } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + process.stderr.write(summary + "\n"); + if (viewerPath) { + process.stderr.write(`Trajectory viewer: ${viewerPath}\n`); + } + } - console.log(summary); + const threshold = Number.parseInt(options.failLevel ?? "", 10); + if (Number.isFinite(threshold)) { + const total = results.length; + const passed = results.filter((r) => r.verdict === "pass").length; + const passRate = total > 0 ? Math.round((passed / total) * 100) : 0; + if (passRate < threshold) { + outputError( + `Pass rate ${passRate}% (${passed}/${total}) is below threshold ${threshold}%`, + Boolean(options.json) + ); + process.exitCode = 1; + } + } + } catch (error) { + outputError( + `Eval failed: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + } } diff --git a/src/commands/generate.ts b/src/commands/generate.ts index a4c69c7..468e0ec 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,29 +1,88 @@ import path from "path"; + import { analyzeRepo } from "../services/analyzer"; +import type { FileAction } from "../services/generator"; import { generateConfigs } from "../services/generator"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, deriveFileStatus, shouldLog } from "../utils/output"; + +import { instructionsCommand } from "./instructions"; type GenerateOptions = { force?: boolean; + perApp?: boolean; + model?: string; + json?: boolean; + quiet?: boolean; }; -export async function generateCommand(type: string, repoPathArg: string | undefined, options: GenerateOptions): Promise { - const allowed = new Set(["mcp", "vscode"]); - if (!allowed.has(type)) { - console.error("Invalid type. Use: mcp, vscode."); - process.exitCode = 1; +export async function generateCommand( + type: string, + repoPathArg: string | undefined, + options: GenerateOptions +): Promise { + const repoPath = path.resolve(repoPathArg ?? process.cwd()); + + if (type === "instructions" || type === "agents") { + // Delegate to the canonical instructions command + const output = type === "agents" ? path.join(repoPath, "AGENTS.md") : undefined; + await instructionsCommand({ + repo: repoPath, + output, + force: options.force, + model: options.model, + json: options.json, + quiet: options.quiet, + areas: options.perApp + }); return; } - const repoPath = path.resolve(repoPathArg ?? process.cwd()); - const analysis = await analyzeRepo(repoPath); + let analysis; + try { + analysis = await analyzeRepo(repoPath); + } catch (error) { + outputError( + `Failed to analyze repo: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } const selections = [type]; - const result = await generateConfigs({ - repoPath, - analysis, - selections, - force: Boolean(options.force) - }); - - console.log(result.summary); + let genResult; + try { + genResult = await generateConfigs({ + repoPath, + analysis, + selections, + force: Boolean(options.force) + }); + } catch (error) { + outputError( + `Failed to generate configs: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } + + if (options.json) { + const { ok, status } = deriveFileStatus(genResult.files); + const result: CommandResult<{ type: string; files: FileAction[] }> = { + ok, + status, + data: { type, files: genResult.files } + }; + outputResult(result, true); + if (!ok) process.exitCode = 1; + } else { + for (const file of genResult.files) { + if (shouldLog(options)) { + process.stderr.write(`${file.action === "wrote" ? "Wrote" : "Skipped"} ${file.path}\n`); + } + } + if (genResult.files.length === 0 && shouldLog(options)) { + process.stderr.write("No changes made.\n"); + } + } } diff --git a/src/commands/init.ts b/src/commands/init.ts index 4f22389..2861ba0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,35 +1,74 @@ import path from "path"; -import fs from "fs/promises"; + import { checkbox, select } from "@inquirer/prompts"; + import { analyzeRepo } from "../services/analyzer"; +import type { AzureDevOpsOrg, AzureDevOpsProject, AzureDevOpsRepo } from "../services/azureDevops"; +import { + getAzureDevOpsToken, + listOrganizations, + listProjects, + listRepos +} from "../services/azureDevops"; +import type { FileAction } from "../services/generator"; import { generateConfigs } from "../services/generator"; -import { GitHubRepo, listAccessibleRepos } from "../services/github"; -import { cloneRepo, isGitRepo } from "../services/git"; +import { buildAuthedUrl, cloneRepo, isGitRepo, setRemoteUrl } from "../services/git"; +import type { GitHubRepo } from "../services/github"; +import { getGitHubToken, listAccessibleRepos } from "../services/github"; import { generateCopilotInstructions } from "../services/instructions"; -import { ensureDir } from "../utils/fs"; +import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; import { prettyPrintSummary } from "../utils/logger"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, deriveFileStatus, shouldLog } from "../utils/output"; type InitOptions = { github?: boolean; + provider?: string; yes?: boolean; force?: boolean; + model?: string; + json?: boolean; + quiet?: boolean; }; -export async function initCommand(repoPathArg: string | undefined, options: InitOptions): Promise { +export async function initCommand( + repoPathArg: string | undefined, + options: InitOptions +): Promise { let repoPath = path.resolve(repoPathArg ?? process.cwd()); + const provider = options.provider ?? (options.github ? "github" : undefined); + + if (provider && provider !== "github" && provider !== "azure") { + outputError("Invalid provider. Use github or azure.", Boolean(options.json)); + return; + } + + if (options.json && !options.yes) { + outputError("--json requires --yes to skip interactive prompts.", true); + return; + } + + if (options.json && provider) { + outputError( + "--json with --provider is not supported. Use 'primer pr' for non-interactive provider workflows.", + true + ); + return; + } - if (options.github) { - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (provider === "github") { + const token = await getGitHubToken(); if (!token) { - console.error("Set GITHUB_TOKEN or GH_TOKEN to use GitHub mode."); - process.exitCode = 1; + outputError( + "Set GITHUB_TOKEN or GH_TOKEN, or authenticate with GitHub CLI.", + Boolean(options.json) + ); return; } const repos = await listAccessibleRepos(token); if (repos.length === 0) { - console.error("No accessible repositories found."); - process.exitCode = 1; + outputError("No accessible repositories found.", Boolean(options.json)); return; } @@ -42,7 +81,7 @@ export async function initCommand(repoPathArg: string | undefined, options: Init }); const cacheRoot = path.join(process.cwd(), ".primer-cache"); - repoPath = path.join(cacheRoot, selection.owner, selection.name); + repoPath = validateCachePath(cacheRoot, selection.owner, selection.name); await ensureDir(repoPath); const hasGit = await isGitRepo(repoPath); @@ -50,11 +89,91 @@ export async function initCommand(repoPathArg: string | undefined, options: Init await cloneRepo(selection.cloneUrl, repoPath); } } - const analysis = await analyzeRepo(repoPath); - prettyPrintSummary(analysis); + + if (provider === "azure") { + const token = getAzureDevOpsToken(); + if (!token) { + outputError( + "Set AZURE_DEVOPS_PAT (or AZDO_PAT) to use Azure DevOps mode.", + Boolean(options.json) + ); + return; + } + + const orgs = await listOrganizations(token); + if (orgs.length === 0) { + outputError("No Azure DevOps organizations found.", Boolean(options.json)); + return; + } + + const orgSelection = await select({ + message: "Choose an Azure DevOps organization", + choices: orgs.map((org) => ({ + name: org.name, + value: org + })) + }); + + const projects = await listProjects(token, orgSelection.name); + if (projects.length === 0) { + outputError("No Azure DevOps projects found.", Boolean(options.json)); + return; + } + + const projectSelection = await select({ + message: "Choose an Azure DevOps project", + choices: projects.map((project) => ({ + name: project.name, + value: project + })) + }); + + const repos = await listRepos(token, orgSelection.name, projectSelection.name); + if (repos.length === 0) { + outputError("No Azure DevOps repositories found.", Boolean(options.json)); + return; + } + + const repoSelection = await select({ + message: "Choose a repository", + choices: repos.map((repo) => ({ + name: `${repo.name}${repo.isPrivate ? " (private)" : ""}`, + value: repo + })) + }); + + const cacheRoot = path.join(process.cwd(), ".primer-cache"); + repoPath = validateCachePath( + cacheRoot, + orgSelection.name, + projectSelection.name, + repoSelection.name + ); + await ensureDir(repoPath); + + const hasGit = await isGitRepo(repoPath); + if (!hasGit) { + const authedUrl = buildAuthedUrl(repoSelection.cloneUrl, token, "azure"); + await cloneRepo(authedUrl, repoPath); + await setRemoteUrl(repoPath, repoSelection.cloneUrl); + } + } + let analysis; + try { + analysis = await analyzeRepo(repoPath); + } catch (error) { + outputError( + `Failed to analyze repo: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } + if (shouldLog(options)) { + prettyPrintSummary(analysis); + } const selections = options.yes - ? ["instructions"] + ? ["instructions", "mcp", "vscode"] : await checkbox({ message: "What would you like to generate?", choices: [ @@ -65,20 +184,69 @@ export async function initCommand(repoPathArg: string | undefined, options: Init required: true }); + const allFiles: FileAction[] = []; + if (selections.includes("instructions")) { const outputPath = path.join(repoPath, ".github", "copilot-instructions.md"); await ensureDir(path.dirname(outputPath)); - const content = await generateCopilotInstructions({ repoPath }); - await fs.writeFile(outputPath, content, "utf8"); - console.log(`Updated ${path.relative(process.cwd(), outputPath)}`); + try { + const content = await generateCopilotInstructions({ repoPath, model: options.model }); + const { wrote } = await safeWriteFile(outputPath, content, Boolean(options.force)); + allFiles.push({ + path: path.relative(process.cwd(), outputPath), + action: wrote ? "wrote" : "skipped" + }); + if (shouldLog(options)) { + const rel = path.relative(process.cwd(), outputPath); + process.stderr.write((wrote ? `Wrote ${rel}` : `Skipped ${rel} (exists)`) + "\n"); + } + } catch (error) { + outputError( + `Failed to generate instructions: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } } - const result = await generateConfigs({ - repoPath, - analysis, - selections: selections.filter((item) => item !== "instructions"), - force: Boolean(options.force) - }); + let genResult; + try { + genResult = await generateConfigs({ + repoPath, + analysis, + selections: selections.filter((item) => item !== "instructions"), + force: Boolean(options.force) + }); + } catch (error) { + outputError( + `Failed to generate configs: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } + allFiles.push(...genResult.files); - console.log(result.summary); + if (options.json) { + const { ok, status } = deriveFileStatus(allFiles); + const result: CommandResult<{ + selections: string[]; + files: FileAction[]; + analysis: typeof analysis; + }> = { + ok, + status, + data: { selections, files: allFiles, analysis } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + for (const file of genResult.files) { + process.stderr.write(`${file.action === "wrote" ? "Wrote" : "Skipped"} ${file.path}\n`); + } + process.stderr.write("\nNext steps:\n"); + process.stderr.write(" primer readiness Check AI readiness across 9 pillars\n"); + if (analysis.areas && analysis.areas.length > 0) { + process.stderr.write(" primer instructions --areas Generate per-area instructions\n"); + } + process.stderr.write(" primer eval --init Scaffold evaluation test cases\n"); + } } diff --git a/src/commands/instructions.ts b/src/commands/instructions.ts new file mode 100644 index 0000000..75b6572 --- /dev/null +++ b/src/commands/instructions.ts @@ -0,0 +1,188 @@ +import path from "path"; + +import { analyzeRepo } from "../services/analyzer"; +import { + generateCopilotInstructions, + generateAreaInstructions, + writeAreaInstruction +} from "../services/instructions"; +import { ensureDir, safeWriteFile } from "../utils/fs"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; + +type InstructionsOptions = { + repo?: string; + output?: string; + model?: string; + json?: boolean; + quiet?: boolean; + force?: boolean; + areas?: boolean; + areasOnly?: boolean; + area?: string; +}; + +export async function instructionsCommand(options: InstructionsOptions): Promise { + const repoPath = path.resolve(options.repo ?? process.cwd()); + const outputPath = path.resolve( + options.output ?? path.join(repoPath, ".github", "copilot-instructions.md") + ); + const progress = createProgressReporter(!shouldLog(options)); + const wantAreas = options.areas || options.areasOnly || options.area; + + try { + // Generate root instructions unless --areas-only + if (!options.areasOnly && !options.area) { + let content = ""; + try { + progress.update("Generating instructions..."); + content = await generateCopilotInstructions({ + repoPath, + model: options.model + }); + } catch (error) { + const msg = + "Failed to generate instructions with Copilot SDK. " + + "Ensure the Copilot CLI is installed (copilot --version) and logged in. " + + (error instanceof Error ? error.message : String(error)); + outputError(msg, Boolean(options.json)); + if (!wantAreas) return; + } + if (!content && !wantAreas) { + outputError("No instructions were generated.", Boolean(options.json)); + return; + } + + if (content) { + await ensureDir(path.dirname(outputPath)); + const { wrote, reason } = await safeWriteFile(outputPath, content, Boolean(options.force)); + + if (!wrote) { + const relPath = path.relative(process.cwd(), outputPath); + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + if (options.json) { + const result: CommandResult<{ outputPath: string; skipped: true; reason: string }> = { + ok: true, + status: "noop", + data: { outputPath, skipped: true, reason: why } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + progress.update(`Skipped ${relPath}: ${why}`); + } + } else { + const byteCount = Buffer.byteLength(content, "utf8"); + + if (options.json) { + const result: CommandResult<{ outputPath: string; model: string; byteCount: number }> = + { + ok: true, + status: "success", + data: { outputPath, model: options.model ?? "default", byteCount } + }; + outputResult(result, true); + } else if (shouldLog(options)) { + progress.succeed(`Updated ${path.relative(process.cwd(), outputPath)}`); + } + } + } + } + + // Generate area-based instructions + if (wantAreas) { + let analysis; + try { + analysis = await analyzeRepo(repoPath); + } catch (error) { + outputError( + `Failed to analyze repository: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } + const areas = analysis.areas ?? []; + + if (areas.length === 0) { + if (shouldLog(options)) { + progress.update("No areas detected. Use primer.config.json to define custom areas."); + } + return; + } + + const areaFilter = options.area?.toLowerCase(); + const targetAreas = areaFilter + ? areas.filter((a) => a.name.toLowerCase() === areaFilter) + : areas; + + if (options.area && targetAreas.length === 0) { + outputError( + `Area "${options.area}" not found. Available: ${areas.map((a) => a.name).join(", ")}`, + Boolean(options.json) + ); + return; + } + + if (shouldLog(options)) { + progress.update(`Generating file-based instructions for ${targetAreas.length} area(s)...`); + } + + for (const area of targetAreas) { + try { + if (shouldLog(options)) { + progress.update( + `Generating for "${area.name}" (${Array.isArray(area.applyTo) ? area.applyTo.join(", ") : area.applyTo})...` + ); + } + const body = await generateAreaInstructions({ + repoPath, + area, + model: options.model, + onProgress: shouldLog(options) ? (msg) => progress.update(msg) : undefined + }); + + if (!body.trim()) { + if (shouldLog(options)) { + progress.update(`Skipped "${area.name}" — no content generated.`); + } + continue; + } + + const result = await writeAreaInstruction(repoPath, area, body, options.force); + if (result.status === "skipped") { + if (shouldLog(options)) { + progress.update(`Skipped "${area.name}" — file exists (use --force to overwrite).`); + } + continue; + } + if (result.status === "symlink") { + if (shouldLog(options)) { + progress.update(`Skipped "${area.name}" — path is a symlink.`); + } + continue; + } + if (shouldLog(options)) { + progress.succeed(`Wrote ${path.relative(process.cwd(), result.filePath)}`); + } + } catch (error) { + if (shouldLog(options)) { + progress.update( + `Failed for "${area.name}": ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + } + + if (!wantAreas && shouldLog(options) && !options.json) { + process.stderr.write("\nNext steps:\n"); + process.stderr.write(" primer eval --init Scaffold evaluation test cases\n"); + process.stderr.write(" primer generate mcp Generate MCP configuration\n"); + process.stderr.write(" primer generate vscode Generate VS Code settings\n"); + } + } catch (error) { + outputError( + `Instructions failed: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + } +} diff --git a/src/commands/instructions.tsx b/src/commands/instructions.tsx deleted file mode 100644 index aa60795..0000000 --- a/src/commands/instructions.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import path from "path"; -import fs from "fs/promises"; -import { generateCopilotInstructions } from "../services/instructions"; -import { ensureDir } from "../utils/fs"; - -type InstructionsOptions = { - repo?: string; - output?: string; - model?: string; -}; - -export async function instructionsCommand(options: InstructionsOptions): Promise { - const repoPath = path.resolve(options.repo ?? process.cwd()); - const outputPath = path.resolve( - options.output ?? path.join(repoPath, ".github", "copilot-instructions.md") - ); - - let content = ""; - try { - content = await generateCopilotInstructions({ - repoPath, - model: options.model - }); - } catch (error) { - console.error("Failed to generate instructions with Copilot SDK."); - console.error("Ensure the Copilot CLI is installed (copilot --version) and logged in (run 'copilot' then '/login')." ); - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - return; - } - if (!content) { - console.error("No instructions were generated."); - process.exitCode = 1; - return; - } - - await ensureDir(path.dirname(outputPath)); - await fs.writeFile(outputPath, content, "utf8"); - - console.log(`Updated ${path.relative(process.cwd(), outputPath)}`); - console.log("Please review and share feedback on any unclear or incomplete sections."); -} \ No newline at end of file diff --git a/src/commands/pr.ts b/src/commands/pr.ts index d8ce79b..632b16c 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -1,91 +1,234 @@ import path from "path"; + import { analyzeRepo } from "../services/analyzer"; +import { + createPullRequest as createAzurePullRequest, + getAzureDevOpsToken, + getRepo as getAzureRepo +} from "../services/azureDevops"; +import { sanitizeError } from "../services/batch"; import { generateConfigs } from "../services/generator"; -import { createPullRequest, getRepo } from "../services/github"; -import { checkoutBranch, cloneRepo, commitAll, isGitRepo, pushBranch } from "../services/git"; -import { ensureDir } from "../utils/fs"; +import { + buildAuthedUrl, + checkoutBranch, + cloneRepo, + commitAll, + isGitRepo, + pushBranch, + setRemoteUrl +} from "../services/git"; +import { createPullRequest, getRepo, getGitHubToken } from "../services/github"; +import { generateCopilotInstructions } from "../services/instructions"; +import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, createProgressReporter, shouldLog } from "../utils/output"; +import { buildFullPrBody } from "../utils/pr"; +import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../utils/repo"; + +const DEFAULT_PR_BRANCH = "primer/add-ai-config"; type PrOptions = { branch?: string; + provider?: string; + model?: string; + json?: boolean; + quiet?: boolean; }; export async function prCommand(repo: string | undefined, options: PrOptions): Promise { + const provider = options.provider ?? "github"; + const progress = createProgressReporter(!shouldLog(options)); + + if (provider !== "github" && provider !== "azure") { + outputError("Invalid provider. Use github or azure.", Boolean(options.json)); + return; + } + if (!repo) { - console.error("Provide a repo in owner/name form."); - process.exitCode = 1; + outputError( + "Provide a repo identifier (github: owner/name, azure: org/project/repo).", + Boolean(options.json) + ); return; } - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (provider === "azure") { + const token = getAzureDevOpsToken(); + if (!token) { + outputError( + "Set AZURE_DEVOPS_PAT (or AZDO_PAT) to use Azure DevOps PR automation.", + Boolean(options.json) + ); + return; + } + + const match = repo.match(AZURE_REPO_RE); + if (!match) { + outputError("Invalid Azure DevOps repo format. Use org/project/repo.", Boolean(options.json)); + return; + } + const [, organization, project, name] = match; + + try { + progress.update("Fetching repo info..."); + const repoInfo = await getAzureRepo(token, organization, project, name); + const cacheRoot = path.join(process.cwd(), ".primer-cache"); + const repoPath = validateCachePath(cacheRoot, organization, project, name); + await ensureDir(repoPath); + + if (!(await isGitRepo(repoPath))) { + progress.update("Cloning..."); + const authedUrl = buildAuthedUrl(repoInfo.cloneUrl, token, "azure"); + await cloneRepo(authedUrl, repoPath); + await setRemoteUrl(repoPath, repoInfo.cloneUrl); + } + + const branch = options.branch ?? DEFAULT_PR_BRANCH; + progress.update("Creating branch..."); + await checkoutBranch(repoPath, branch); + + progress.update("Generating instructions..."); + const instructions = await generateCopilotInstructions({ repoPath, model: options.model }); + const instructionsPath = path.join(repoPath, ".github", "copilot-instructions.md"); + await ensureDir(path.dirname(instructionsPath)); + const { wrote, reason } = await safeWriteFile(instructionsPath, instructions, true); + if (!wrote) { + throw new Error( + `Refused to write instructions (${reason === "symlink" ? "path is a symlink" : "file exists"})` + ); + } + + progress.update("Analyzing..."); + const analysis = await analyzeRepo(repoPath); + progress.update("Generating configs..."); + await generateConfigs({ + repoPath, + analysis, + selections: ["mcp", "vscode"], + force: true + }); + + progress.update("Committing..."); + await commitAll(repoPath, "chore: add AI configurations via Primer"); + progress.update("Pushing..."); + await pushBranch(repoPath, branch, token, "azure"); + + progress.update("Creating PR..."); + const prUrl = await createAzurePullRequest({ + token, + organization, + project, + repoId: repoInfo.id, + repoName: repoInfo.name, + title: "🤖 Prime this repo for AI", + body: buildFullPrBody(), + sourceBranch: branch, + targetBranch: repoInfo.defaultBranch + }); + + if (options.json) { + const result: CommandResult<{ repo: string; branch: string; prUrl: string }> = { + ok: true, + status: "success", + data: { repo, branch, prUrl } + }; + outputResult(result, true); + } else { + progress.succeed(`Created PR: ${prUrl}`); + } + } catch (error) { + outputError( + sanitizeError(`PR failed: ${error instanceof Error ? error.message : String(error)}`), + Boolean(options.json) + ); + } + return; + } + + // GitHub provider + const token = await getGitHubToken(); if (!token) { - console.error("Set GITHUB_TOKEN or GH_TOKEN to use PR automation."); - process.exitCode = 1; + outputError( + "Set GITHUB_TOKEN or GH_TOKEN, or authenticate with GitHub CLI.", + Boolean(options.json) + ); return; } - const [owner, name] = repo.split("/"); - if (!owner || !name) { - console.error("Invalid repo format. Use owner/name."); - process.exitCode = 1; + const match = repo.match(GITHUB_REPO_RE); + if (!match) { + outputError("Invalid repo format. Use owner/name.", Boolean(options.json)); return; } + const [, owner, name] = match; - const repoInfo = await getRepo(token, owner, name); - const cacheRoot = path.join(process.cwd(), ".primer-cache"); - const repoPath = path.join(cacheRoot, owner, name); - await ensureDir(repoPath); + try { + progress.update("Fetching repo info..."); + const repoInfo = await getRepo(token, owner, name); + const cacheRoot = path.join(process.cwd(), ".primer-cache"); + const repoPath = validateCachePath(cacheRoot, owner, name); + await ensureDir(repoPath); - if (!(await isGitRepo(repoPath))) { - await cloneRepo(repoInfo.cloneUrl, repoPath); - } + if (!(await isGitRepo(repoPath))) { + progress.update("Cloning..."); + await cloneRepo(repoInfo.cloneUrl, repoPath); + } - const branch = options.branch ?? "primer/add-configs"; - await checkoutBranch(repoPath, branch); - - const analysis = await analyzeRepo(repoPath); - await generateConfigs({ - repoPath, - analysis, - selections: ["mcp", "vscode"], - force: true - }); - - await commitAll(repoPath, "chore: add AI configurations via Primer"); - await pushBranch(repoPath, branch); - - const prUrl = await createPullRequest({ - token, - owner, - repo: name, - title: "🤖 Prime this repo for AI", - body: buildPrBody(), - head: `${owner}:${branch}`, - base: repoInfo.defaultBranch - }); - - console.log(`Created PR: ${prUrl}`); -} + const branch = options.branch ?? DEFAULT_PR_BRANCH; + progress.update("Creating branch..."); + await checkoutBranch(repoPath, branch); + + progress.update("Generating instructions..."); + const instructions = await generateCopilotInstructions({ repoPath, model: options.model }); + const instructionsPath = path.join(repoPath, ".github", "copilot-instructions.md"); + await ensureDir(path.dirname(instructionsPath)); + const { wrote, reason } = await safeWriteFile(instructionsPath, instructions, true); + if (!wrote) { + throw new Error( + `Refused to write instructions (${reason === "symlink" ? "path is a symlink" : "file exists"})` + ); + } + + progress.update("Analyzing..."); + const analysis = await analyzeRepo(repoPath); + progress.update("Generating configs..."); + await generateConfigs({ + repoPath, + analysis, + selections: ["mcp", "vscode"], + force: true + }); -function buildPrBody(): string { - return [ - "## 🤖 Primed for AI", - "", - "This PR adds configurations to prime this repository for AI coding assistants.", - "", - "### Added Files", - "", - "| File | Purpose |", - "|------|---------|", - "| `.vscode/settings.json` | VS Code settings for optimal AI assistance |", - "| `.vscode/mcp.json` | Model Context Protocol server configuration |", - "", - "### How to Use", - "", - "1. Merge this PR", - "2. Open the project in VS Code", - "3. Start chatting with Copilot — it now understands your project!", - "", - "---", - "*Generated by Primer*" - ].join("\n"); + progress.update("Committing..."); + await commitAll(repoPath, "chore: add AI configurations via Primer"); + progress.update("Pushing..."); + await pushBranch(repoPath, branch, token, "github"); + + progress.update("Creating PR..."); + const prUrl = await createPullRequest({ + token, + owner, + repo: name, + title: "🤖 Prime this repo for AI", + body: buildFullPrBody(), + head: `${owner}:${branch}`, + base: repoInfo.defaultBranch + }); + + if (options.json) { + const result: CommandResult<{ repo: string; branch: string; prUrl: string }> = { + ok: true, + status: "success", + data: { repo, branch, prUrl } + }; + outputResult(result, true); + } else { + progress.succeed(`Created PR: ${prUrl}`); + } + } catch (error) { + outputError( + sanitizeError(`PR failed: ${error instanceof Error ? error.message : String(error)}`), + Boolean(options.json) + ); + } } diff --git a/src/commands/readiness.ts b/src/commands/readiness.ts new file mode 100644 index 0000000..0552077 --- /dev/null +++ b/src/commands/readiness.ts @@ -0,0 +1,381 @@ +import path from "path"; + +import chalk from "chalk"; + +import { parsePolicySources } from "../services/policy"; +import type { + ReadinessReport, + ReadinessCriterionResult, + AreaReadinessReport, + ReadinessPillarSummary +} from "../services/readiness"; +import { runReadinessReport, groupPillars } from "../services/readiness"; +import { generateVisualReport } from "../services/visualReport"; +import { safeWriteFile } from "../utils/fs"; +import type { CommandResult } from "../utils/output"; +import { outputResult, outputError, shouldLog } from "../utils/output"; + +type ReadinessOptions = { + json?: boolean; + quiet?: boolean; + output?: string; + force?: boolean; + visual?: boolean; + perArea?: boolean; + policy?: string; + failLevel?: string; +}; + +export async function readinessCommand( + repoPathArg: string | undefined, + options: ReadinessOptions +): Promise { + const repoPath = path.resolve(repoPathArg ?? process.cwd()); + const repoName = path.basename(repoPath); + const resolvedOutputPath = options.output ? path.resolve(options.output) : ""; + const outputExt = options.output ? path.extname(options.output).toLowerCase() : ""; + let failLevelError: string | undefined; + + let report: ReadinessReport; + try { + const policies = parsePolicySources(options.policy); + report = await runReadinessReport({ repoPath, perArea: options.perArea, policies }); + } catch (error) { + outputError( + `Failed to generate readiness report: ${error instanceof Error ? error.message : String(error)}`, + Boolean(options.json) + ); + return; + } + + // Check --fail-level threshold early so it applies regardless of output format + const failLevel = Number.parseInt(options.failLevel ?? "", 10); + if (Number.isFinite(failLevel)) { + const clamped = Math.max(1, Math.min(5, failLevel)); + if (clamped !== failLevel && shouldLog(options)) { + process.stderr.write(`Warning: --fail-level clamped to ${clamped} (valid range: 1–5)\n`); + } + if ((report.achievedLevel ?? 0) < clamped) { + failLevelError = `Readiness level ${report.achievedLevel ?? 0} is below threshold ${clamped}`; + if (shouldLog(options)) { + process.stderr.write(`Error: ${failLevelError}\n`); + } + process.exitCode = 1; + } + } + + const jsonResult: CommandResult = failLevelError + ? { + ok: false, + status: "error", + data: report, + errors: [failLevelError] + } + : { + ok: true, + status: "success", + data: report + }; + const emitJsonResult = (): void => outputResult(jsonResult, true); + + // Validate output extension early, before any output branch + if (options.output) { + if (outputExt !== ".json" && outputExt !== ".md" && outputExt !== ".html") { + outputError( + `Unsupported output format: ${outputExt || "(no extension)"}. Use .json, .md, or .html`, + Boolean(options.json) + ); + return; + } + } + + if (options.visual && outputExt && outputExt !== ".html") { + outputError( + `Cannot use --visual with ${outputExt} output. Use a .html output path or omit --output.`, + Boolean(options.json) + ); + return; + } + + // Generate visual HTML report + if (options.visual || outputExt === ".html") { + const html = generateVisualReport({ + reports: [{ repo: repoName, report }], + title: `AI Readiness Report: ${repoName}`, + generatedAt: new Date().toISOString() + }); + + const outputPath = options.output + ? resolvedOutputPath + : path.join(repoPath, "readiness-report.html"); + + const { wrote, reason } = await safeWriteFile(outputPath, html, Boolean(options.force)); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + outputError(`Skipped ${outputPath}: ${why}`, Boolean(options.json)); + return; + } + if (shouldLog(options)) { + process.stderr.write(chalk.green(`✓ Visual report generated: ${outputPath}`) + "\n"); + } + if (options.json) { + emitJsonResult(); + } + return; + } + + // Output to Markdown file + if (outputExt === ".md") { + const md = formatReadinessMarkdown(report, repoName); + const { wrote, reason } = await safeWriteFile(resolvedOutputPath, md, Boolean(options.force)); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + outputError(`Skipped ${resolvedOutputPath}: ${why}`, Boolean(options.json)); + return; + } + if (shouldLog(options)) { + process.stderr.write(chalk.green(`✓ Markdown report saved: ${resolvedOutputPath}`) + "\n"); + } + if (options.json) { + emitJsonResult(); + } + return; + } + + // Output to JSON file + if (outputExt === ".json") { + const { wrote, reason } = await safeWriteFile( + resolvedOutputPath, + JSON.stringify(report, null, 2), + Boolean(options.force) + ); + if (!wrote) { + const why = reason === "symlink" ? "path is a symlink" : "file exists (use --force)"; + outputError(`Skipped ${resolvedOutputPath}: ${why}`, Boolean(options.json)); + return; + } + if (shouldLog(options)) { + process.stderr.write(chalk.green(`✓ JSON report saved: ${resolvedOutputPath}`) + "\n"); + } + if (options.json) { + emitJsonResult(); + } + return; + } + + if (options.json) { + emitJsonResult(); + return; + } + + if (shouldLog(options)) { + printReadinessChecklist(report); + } +} + +function printReadinessChecklist(report: ReadinessReport): void { + const log = (msg: string) => process.stderr.write(msg + "\n"); + log(chalk.bold("Readiness report")); + log(`- Repo: ${report.repoPath}`); + log( + `- Monorepo: ${report.isMonorepo ? "yes" : "no"}${report.apps.length ? ` (${report.apps.length} apps)` : ""}` + ); + log(`- Level: ${report.achievedLevel ?? 1} (${levelName(report.achievedLevel ?? 1)})`); + + const groups = groupPillars(report.pillars); + for (const { label, pillars } of groups) { + if (pillars.length === 0) continue; + log(chalk.bold(`\n${label}`)); + for (const pillar of pillars) { + const rate = formatPercent(pillar.passRate); + const icon = pillar.passRate >= 0.8 ? chalk.green("●") : chalk.yellow("●"); + log(`${icon} ${pillar.name}: ${pillar.passed}/${pillar.total} (${rate})`); + } + } + + log(chalk.bold("\nFix first")); + const fixes = rankFixes(report.criteria); + if (!fixes.length) { + log(chalk.green("✔ No failing criteria detected.")); + } else { + for (const fix of fixes) { + const impact = colorImpact(fix.impact); + const effort = colorEffort(fix.effort); + const scope = fix.scope === "app" ? "app" : "repo"; + const detail = fix.appSummary + ? ` (${fix.appSummary.passed}/${fix.appSummary.total} apps)` + : ""; + log(`- ${impact} impact / ${effort} effort • ${fix.title}${detail} [${scope}]`); + if (fix.reason) { + log(` ${chalk.dim(fix.reason)}`); + } + if (fix.appFailures?.length) { + log(` ${chalk.dim(`Apps: ${fix.appFailures.join(", ")}`)}`); + } + } + } + + if (report.extras.length) { + log(chalk.bold("\nAI readiness extras")); + for (const extra of report.extras) { + const icon = extra.status === "pass" ? chalk.green("✔") : chalk.red("✖"); + log(`${icon} ${extra.title}`); + } + } + + if (report.areaReports?.length) { + printAreaBreakdown(report.areaReports); + } +} + +function rankFixes(criteria: ReadinessCriterionResult[]): ReadinessCriterionResult[] { + return criteria + .filter((criterion) => criterion.status === "fail") + .sort((a, b) => { + const impactDelta = impactWeight(b.impact) - impactWeight(a.impact); + if (impactDelta !== 0) return impactDelta; + return effortWeight(a.effort) - effortWeight(b.effort); + }); +} + +function impactWeight(value: "high" | "medium" | "low"): number { + if (value === "high") return 3; + if (value === "medium") return 2; + return 1; +} + +function effortWeight(value: "low" | "medium" | "high"): number { + if (value === "low") return 1; + if (value === "medium") return 2; + return 3; +} + +function colorImpact(value: "high" | "medium" | "low"): string { + if (value === "high") return chalk.red("High"); + if (value === "medium") return chalk.yellow("Medium"); + return chalk.green("Low"); +} + +function colorEffort(value: "low" | "medium" | "high"): string { + if (value === "high") return chalk.red("High"); + if (value === "medium") return chalk.yellow("Medium"); + return chalk.green("Low"); +} + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function levelName(level: number): string { + if (level === 2) return "Documented"; + if (level === 3) return "Standardized"; + if (level === 4) return "Optimized"; + if (level === 5) return "Autonomous"; + return "Functional"; +} + +function printAreaBreakdown(areaReports: AreaReadinessReport[]): void { + const log = (msg: string) => process.stderr.write(msg + "\n"); + log(chalk.bold("\nPer-area breakdown")); + for (const ar of areaReports) { + // Sum across all pillar summaries for this area + const passed = ar.pillars.reduce((sum, p) => sum + p.passed, 0); + const total = ar.pillars.reduce((sum, p) => sum + p.total, 0); + const pct = total ? Math.round((passed / total) * 100) : 0; + const icon = total > 0 && passed / total >= 0.8 ? chalk.green("●") : chalk.yellow("●"); + const source = ar.area.source === "config" ? chalk.dim(" (config)") : ""; + log(`${icon} ${ar.area.name}${source}: ${passed}/${total} (${pct}%)`); + + const failures = ar.criteria.filter((c) => c.status === "fail"); + for (const f of failures) { + log(` ${chalk.red("✖")} ${f.title}${f.reason ? ` — ${chalk.dim(f.reason)}` : ""}`); + } + } +} + +export function formatReadinessMarkdown(report: ReadinessReport, repoName: string): string { + const lines: string[] = []; + + lines.push(`# AI Readiness Report: ${repoName}`); + lines.push(""); + lines.push(`**Level ${report.achievedLevel}** — ${levelName(report.achievedLevel)}`); + lines.push(""); + + // Pillar summary table + const groups = groupPillars(report.pillars); + for (const { label, pillars } of groups) { + if (pillars.length === 0) continue; + lines.push(`## ${label}`); + lines.push(""); + lines.push("| Pillar | Passed | Total | Rate |"); + lines.push("| --- | ---: | ---: | ---: |"); + for (const pillar of pillars) { + const icon = pillar.passRate >= 0.8 ? "✅" : "⚠️"; + lines.push( + `| ${icon} ${pillar.name} | ${pillar.passed} | ${pillar.total} | ${formatPercent(pillar.passRate)} |` + ); + } + lines.push(""); + } + + // Fix-first list + const fixes = rankFixes(report.criteria); + if (fixes.length > 0) { + lines.push("## Fix First"); + lines.push(""); + for (const fix of fixes) { + const detail = fix.appSummary + ? ` (${fix.appSummary.passed}/${fix.appSummary.total} apps)` + : ""; + lines.push(`- **${fix.title}**${detail} — ${fix.impact} impact, ${fix.effort} effort`); + if (fix.reason) { + lines.push(` - ${fix.reason}`); + } + } + lines.push(""); + } + + // Extras + if (report.extras.length > 0) { + lines.push("## AI Readiness Extras"); + lines.push(""); + for (const extra of report.extras) { + const icon = extra.status === "pass" ? "✅" : "❌"; + lines.push(`- ${icon} ${extra.title}`); + } + lines.push(""); + } + + // Area breakdown + if (report.areaReports?.length) { + lines.push("## Per-Area Breakdown"); + lines.push(""); + for (const ar of report.areaReports) { + const passed = ar.pillars.reduce( + (sum: number, p: ReadinessPillarSummary) => sum + p.passed, + 0 + ); + const total = ar.pillars.reduce((sum: number, p: ReadinessPillarSummary) => sum + p.total, 0); + const pct = total ? Math.round((passed / total) * 100) : 0; + lines.push(`### ${ar.area.name} — ${passed}/${total} (${pct}%)`); + lines.push(""); + const failures = ar.criteria.filter((c) => c.status === "fail"); + if (failures.length > 0) { + for (const f of failures) { + lines.push(`- ❌ ${f.title}${f.reason ? ` — ${f.reason}` : ""}`); + } + } else { + lines.push("All criteria passing."); + } + lines.push(""); + } + } + + lines.push("---"); + lines.push( + `*Generated by [Primer](https://github.com/pierceboggan/primer) on ${report.generatedAt}*` + ); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/commands/templates.ts b/src/commands/templates.ts deleted file mode 100644 index ad8c643..0000000 --- a/src/commands/templates.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function templatesCommand(): Promise { - console.log("Available templates: mcp, vscode"); -} diff --git a/src/commands/tui.tsx b/src/commands/tui.tsx index db6c55e..e1f56a0 100644 --- a/src/commands/tui.tsx +++ b/src/commands/tui.tsx @@ -1,16 +1,27 @@ import path from "path"; -import React from "react"; + import { render } from "ink"; +import React from "react"; + import { PrimerTui } from "../ui/tui"; +import { outputError } from "../utils/output"; type TuiOptions = { repo?: string; animation?: boolean; + json?: boolean; + quiet?: boolean; }; export async function tuiCommand(options: TuiOptions): Promise { const repoPath = path.resolve(options.repo ?? process.cwd()); const skipAnimation = options.animation === false; - const { waitUntilExit } = render(); - await waitUntilExit(); + try { + const { waitUntilExit } = render( + + ); + await waitUntilExit(); + } catch (error) { + outputError(`TUI failed: ${error instanceof Error ? error.message : String(error)}`, false); + } } diff --git a/src/commands/update.ts b/src/commands/update.ts deleted file mode 100644 index b828b40..0000000 --- a/src/commands/update.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function updateCommand(): Promise { - console.log("Update is not implemented yet."); -} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..a46583c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,2 @@ +export const DEFAULT_MODEL = "claude-sonnet-4.5"; +export const DEFAULT_JUDGE_MODEL = "claude-sonnet-4.5"; diff --git a/src/index.ts b/src/index.ts index ce2fa64..7c9a934 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { runCli } from "./cli"; const [, , ...args] = process.argv; if (args.length === 0) { - runCli([process.argv[0], process.argv[1], "tui"]); + runCli([process.argv[0], process.argv[1], "tui"]); } else { - runCli(process.argv); + runCli(process.argv); } diff --git a/src/services/__tests__/analyze-output.test.ts b/src/services/__tests__/analyze-output.test.ts new file mode 100644 index 0000000..d0a8374 --- /dev/null +++ b/src/services/__tests__/analyze-output.test.ts @@ -0,0 +1,183 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { analyzeCommand, formatAnalysisMarkdown } from "../../commands/analyze"; +import type { RepoAnalysis } from "../analyzer"; + +describe("formatAnalysisMarkdown", () => { + function makeAnalysis(overrides: Partial = {}): RepoAnalysis { + return { + path: "/tmp/test-repo", + isGitRepo: true, + languages: ["TypeScript", "JavaScript"], + frameworks: ["React", "Next.js"], + packageManager: "npm", + ...overrides + }; + } + + it("renders a heading with repo name", () => { + const md = formatAnalysisMarkdown(makeAnalysis()); + expect(md).toContain("# Repository Analysis: test-repo"); + }); + + it("includes overview table", () => { + const md = formatAnalysisMarkdown(makeAnalysis()); + expect(md).toContain("| Languages | TypeScript, JavaScript |"); + expect(md).toContain("| Frameworks | React, Next.js |"); + expect(md).toContain("| Package manager | npm |"); + }); + + it("shows monorepo info when present", () => { + const md = formatAnalysisMarkdown( + makeAnalysis({ + isMonorepo: true, + workspaceType: "pnpm", + apps: [ + { + name: "app-a", + path: "/tmp/test-repo/apps/a", + ecosystem: "node", + packageJsonPath: "", + scripts: {}, + hasTsConfig: true + } + ] + }) + ); + expect(md).toContain("| Monorepo | pnpm (1 apps) |"); + expect(md).toContain("## Applications"); + expect(md).toContain("| app-a |"); + }); + + it("shows areas when present", () => { + const md = formatAnalysisMarkdown( + makeAnalysis({ + areas: [ + { name: "frontend", applyTo: "frontend/**", source: "auto" }, + { name: "backend", applyTo: "backend/**", source: "config" } + ] + }) + ); + expect(md).toContain("## Areas"); + expect(md).toContain("| frontend | auto |"); + expect(md).toContain("| backend | config |"); + }); + + it("handles empty languages gracefully", () => { + const md = formatAnalysisMarkdown(makeAnalysis({ languages: [], frameworks: [] })); + expect(md).toContain("| Languages | Unknown |"); + expect(md).toContain("| Frameworks | None detected |"); + }); + + it("includes primer footer", () => { + const md = formatAnalysisMarkdown(makeAnalysis()); + expect(md).toContain("Primer"); + }); +}); + +describe("analyzeCommand --output", () => { + let tmpDir: string; + + async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "analyze-output-")); + // Create a minimal directory so analyzeRepo can run against it + await fs.mkdir(path.join(tmpDir, "repo")); + return path.join(tmpDir, "repo"); + } + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("writes JSON file when output ends in .json", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + await analyzeCommand(repoPath, { output: out }); + const content = await fs.readFile(out, "utf-8"); + const parsed = JSON.parse(content); + expect(parsed).toHaveProperty("path"); + expect(parsed).toHaveProperty("languages"); + }); + + it("writes Markdown file when output ends in .md", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.md"); + await analyzeCommand(repoPath, { output: out }); + const content = await fs.readFile(out, "utf-8"); + expect(content).toContain("# Repository Analysis:"); + }); + + it("refuses to overwrite without --force", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + await fs.writeFile(out, "existing"); + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await analyzeCommand(repoPath, { output: out }); + spy.mockRestore(); + // File should still have original content + const content = await fs.readFile(out, "utf-8"); + expect(content).toBe("existing"); + }); + + it("overwrites with --force", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + await fs.writeFile(out, "existing"); + await analyzeCommand(repoPath, { output: out, force: true }); + const content = await fs.readFile(out, "utf-8"); + expect(content).not.toBe("existing"); + expect(JSON.parse(content)).toHaveProperty("path"); + }); + + it("rejects unsupported extensions", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.txt"); + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await analyzeCommand(repoPath, { output: out }); + spy.mockRestore(); + expect(process.exitCode).toBe(1); + await expect(fs.access(out)).rejects.toThrow(); + }); + + it("rejects symlinks", async () => { + const repoPath = await setup(); + const real = path.join(tmpDir, "real.json"); + await fs.writeFile(real, "x"); + const link = path.join(tmpDir, "link.json"); + await fs.symlink(real, link); + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await analyzeCommand(repoPath, { output: link }); + spy.mockRestore(); + // Real file content unchanged + const content = await fs.readFile(real, "utf-8"); + expect(content).toBe("x"); + }); + + it("emits JSON to stdout when --json is used with --output", async () => { + const repoPath = await setup(); + const out = path.join(tmpDir, "report.json"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await analyzeCommand(repoPath, { output: out, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(out, "utf-8"); + expect(JSON.parse(fileContent)).toHaveProperty("path"); + }); +}); diff --git a/src/services/__tests__/analyzer.test.ts b/src/services/__tests__/analyzer.test.ts new file mode 100644 index 0000000..39b0021 --- /dev/null +++ b/src/services/__tests__/analyzer.test.ts @@ -0,0 +1,1059 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { analyzeRepo, loadPrimerConfig, sanitizeAreaName, type Area } from "../analyzer"; +import { + buildAreaFrontmatter, + buildAreaInstructionContent, + areaInstructionPath, + writeAreaInstruction +} from "../instructions"; + +describe("analyzeRepo", () => { + const tmpDirs: string[] = []; + + async function makeTmpDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-test-")); + tmpDirs.push(dir); + return dir; + } + + afterEach(async () => { + for (const dir of tmpDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + tmpDirs.length = 0; + }); + + it("detects TypeScript and npm workspace", async () => { + const repoPath = await makeTmpDir(); + const packageJson = { + name: "demo", + workspaces: ["packages/*"], + dependencies: { react: "^19.0.0" } + }; + + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify(packageJson, null, 2)); + await fs.writeFile(path.join(repoPath, "tsconfig.json"), "{}", "utf8"); + await fs.mkdir(path.join(repoPath, "packages", "app"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "app", "package.json"), + JSON.stringify({ name: "app", scripts: { build: "tsc" } }, null, 2) + ); + + const result = await analyzeRepo(repoPath); + + expect(result.languages).toContain("TypeScript"); + expect(result.workspaceType).toBe("npm"); + expect(result.apps?.length).toBe(1); + }); + + it("detects C# language", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "MyProject.csproj"), "", "utf8"); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C#"); + }); + + it("detects Java via pom.xml", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "pom.xml"), "", "utf8"); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("Java"); + expect(result.packageManager).toBe("maven"); + }); + + it("detects Java via build.gradle", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "build.gradle"), "plugins {}", "utf8"); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("Java"); + expect(result.packageManager).toBe("gradle"); + }); + + it("detects Ruby", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "Gemfile"), "source 'https://rubygems.org'", "utf8"); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("Ruby"); + expect(result.packageManager).toBe("bundler"); + }); + + it("detects PHP", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "composer.json"), "{}", "utf8"); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("PHP"); + expect(result.packageManager).toBe("composer"); + }); + + it("returns empty analysis for empty directory", async () => { + const repoPath = await makeTmpDir(); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toEqual([]); + expect(result.frameworks).toEqual([]); + expect(result.packageManager).toBeUndefined(); + }); + + it("detects pnpm workspace with comments in YAML", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify({ name: "root" })); + await fs.writeFile(path.join(repoPath, "pnpm-lock.yaml"), "lockfileVersion: 9"); + await fs.writeFile( + path.join(repoPath, "pnpm-workspace.yaml"), + ["# workspace config", "packages:", " - 'apps/*' # main apps", " - 'libs/*'", "# end"].join( + "\n" + ) + ); + await fs.mkdir(path.join(repoPath, "apps", "web"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "apps", "web", "package.json"), + JSON.stringify({ name: "web", scripts: { build: "tsc" } }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.workspaceType).toBe("pnpm"); + expect(result.workspacePatterns).toContain("apps/*"); + expect(result.workspacePatterns).toContain("libs/*"); + // Should not include comment text in patterns + expect(result.workspacePatterns?.some((p) => p.includes("#"))).toBe(false); + }); + + it("detects pnpm inline array workspace", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify({ name: "root" })); + await fs.writeFile(path.join(repoPath, "pnpm-lock.yaml"), "lockfileVersion: 9"); + await fs.writeFile( + path.join(repoPath, "pnpm-workspace.yaml"), + 'packages: ["apps/*", "libs/*"]\n' + ); + + const result = await analyzeRepo(repoPath); + expect(result.workspaceType).toBe("pnpm"); + expect(result.workspacePatterns).toContain("apps/*"); + expect(result.workspacePatterns).toContain("libs/*"); + }); + + it("detects Cargo workspace (Rust monorepo)", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "Cargo.toml"), + [ + "[workspace]", + 'members = ["crates/core", "crates/cli"]', + "", + "[package]", + 'name = "root"' + ].join("\n") + ); + await fs.mkdir(path.join(repoPath, "crates", "core"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "crates", "core", "Cargo.toml"), + '[package]\nname = "my-core"\nversion = "0.1.0"' + ); + await fs.mkdir(path.join(repoPath, "crates", "cli"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "crates", "cli", "Cargo.toml"), + '[package]\nname = "my-cli"\nversion = "0.1.0"' + ); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("Rust"); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("cargo"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["my-cli", "my-core"]); + expect(result.apps?.[0].ecosystem).toBe("rust"); + }); + + it("detects Go workspace (go.work)", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "go.work"), + ["go 1.21", "", "use (", " ./cmd/server", " ./pkg/lib", ")"].join("\n") + ); + await fs.mkdir(path.join(repoPath, "cmd", "server"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "cmd", "server", "go.mod"), + "module github.com/example/server\n\ngo 1.21" + ); + await fs.mkdir(path.join(repoPath, "pkg", "lib"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "pkg", "lib", "go.mod"), + "module github.com/example/lib\n\ngo 1.21" + ); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("go"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["lib", "server"]); + expect(result.apps?.[0].ecosystem).toBe("go"); + }); + + it("detects .NET solution (monorepo)", async () => { + const repoPath = await makeTmpDir(); + const slnContent = [ + "Microsoft Visual Studio Solution File, Format Version 12.00", + "# Visual Studio Version 17", + 'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "src\\WebApp\\WebApp.csproj", "{GUID1}"', + "EndProject", + 'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreLib", "src\\CoreLib\\CoreLib.csproj", "{GUID2}"', + "EndProject" + ].join("\n"); + await fs.writeFile(path.join(repoPath, "MySolution.sln"), slnContent); + await fs.mkdir(path.join(repoPath, "src", "WebApp"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "src", "WebApp", "WebApp.csproj"), ""); + await fs.mkdir(path.join(repoPath, "src", "CoreLib"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "src", "CoreLib", "CoreLib.csproj"), ""); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C#"); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("dotnet"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["CoreLib", "WebApp"]); + expect(result.apps?.[0].ecosystem).toBe("dotnet"); + }); + + it("detects Gradle multi-project", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "settings.gradle"), + "rootProject.name = 'my-app'\ninclude ':app', ':lib'" + ); + await fs.writeFile(path.join(repoPath, "build.gradle"), "plugins {}", "utf8"); + await fs.mkdir(path.join(repoPath, "app"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "app", "build.gradle"), "plugins {}"); + await fs.mkdir(path.join(repoPath, "lib"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "lib", "build.gradle"), "plugins {}"); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("Java"); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("gradle"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["app", "lib"]); + expect(result.apps?.[0].ecosystem).toBe("java"); + }); + + it("detects Gradle Kotlin DSL multi-project", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "settings.gradle.kts"), + 'rootProject.name = "my-app"\ninclude(":app", ":server")' + ); + await fs.writeFile(path.join(repoPath, "build.gradle.kts"), "plugins {}", "utf8"); + await fs.mkdir(path.join(repoPath, "app"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "app", "build.gradle.kts"), "plugins {}"); + await fs.mkdir(path.join(repoPath, "server"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "server", "build.gradle.kts"), "plugins {}"); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("gradle"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["app", "server"]); + }); + + it("detects Maven multi-module", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "pom.xml"), + [ + "", + " ", + " api", + " web", + " ", + "" + ].join("\n") + ); + await fs.mkdir(path.join(repoPath, "api"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "api", "pom.xml"), ""); + await fs.mkdir(path.join(repoPath, "web"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "web", "pom.xml"), ""); + + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("Java"); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("maven"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["api", "web"]); + }); + + it("sets ecosystem to node for JS workspace apps", async () => { + const repoPath = await makeTmpDir(); + const packageJson = { + name: "demo", + workspaces: ["packages/*"] + }; + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify(packageJson)); + await fs.mkdir(path.join(repoPath, "packages", "a"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "a", "package.json"), + JSON.stringify({ name: "a" }) + ); + await fs.mkdir(path.join(repoPath, "packages", "b"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "b", "package.json"), + JSON.stringify({ name: "b" }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.apps?.[0].ecosystem).toBe("node"); + expect(result.apps?.[0].manifestPath).toBeTruthy(); + }); + + it("detects areas from heuristic directories", async () => { + const repoPath = await makeTmpDir(); + + // Create heuristic dirs with meaningful content + await fs.mkdir(path.join(repoPath, "frontend"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "frontend", "index.ts"), "export {};"); + await fs.mkdir(path.join(repoPath, "backend"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "backend", "package.json"), "{}"); + + // Empty dir should be skipped + await fs.mkdir(path.join(repoPath, "docs"), { recursive: true }); + + // Non-heuristic dir should be skipped + await fs.mkdir(path.join(repoPath, "random"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "random", "file.ts"), "export {};"); + + const result = await analyzeRepo(repoPath); + const areaNames = (result.areas ?? []).map((a) => a.name).sort(); + expect(areaNames).toContain("frontend"); + expect(areaNames).toContain("backend"); + expect(areaNames).not.toContain("docs"); + expect(areaNames).not.toContain("random"); + }); + + it("detects areas from monorepo apps", async () => { + const repoPath = await makeTmpDir(); + const packageJson = { name: "root", workspaces: ["packages/*"] }; + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify(packageJson)); + await fs.mkdir(path.join(repoPath, "packages", "web"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "web", "package.json"), + JSON.stringify({ name: "web" }) + ); + await fs.mkdir(path.join(repoPath, "packages", "api"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "api", "package.json"), + JSON.stringify({ name: "api" }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.areas?.length).toBe(2); + const areaNames = (result.areas ?? []).map((a) => a.name).sort(); + expect(areaNames).toEqual(["api", "web"]); + expect(result.areas?.[0].source).toBe("auto"); + expect(result.areas?.[0].applyTo).toMatch(/\*\*$/u); + }); + + it("config areas override auto-detected (case-insensitive)", async () => { + const repoPath = await makeTmpDir(); + + // Create a heuristic area + await fs.mkdir(path.join(repoPath, "frontend"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "frontend", "index.ts"), "export {};"); + + // Config overrides "frontend" with different applyTo + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ + areas: [{ name: "Frontend", applyTo: "src/frontend/**", description: "UI layer" }] + }) + ); + + const result = await analyzeRepo(repoPath); + const frontendArea = result.areas?.find((a) => a.name === "Frontend"); + expect(frontendArea).toBeDefined(); + expect(frontendArea?.source).toBe("config"); + expect(frontendArea?.applyTo).toBe("src/frontend/**"); + expect(frontendArea?.description).toBe("UI layer"); + // Should not have duplicate "frontend" (auto) + "Frontend" (config) + const frontendAreas = (result.areas ?? []).filter((a) => a.name.toLowerCase() === "frontend"); + expect(frontendAreas.length).toBe(1); + }); + + it("returns empty areas for empty directory", async () => { + const repoPath = await makeTmpDir(); + const result = await analyzeRepo(repoPath); + expect(result.areas).toEqual([]); + }); + + it("preserves scripts and hasTsConfig from app to area", async () => { + const repoPath = await makeTmpDir(); + const packageJson = { name: "root", workspaces: ["packages/*"] }; + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify(packageJson)); + await fs.mkdir(path.join(repoPath, "packages", "web"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "web", "package.json"), + JSON.stringify({ name: "web", scripts: { build: "next build", test: "jest" } }) + ); + await fs.writeFile(path.join(repoPath, "packages", "web", "tsconfig.json"), "{}"); + // Need 2 apps for monorepo detection + await fs.mkdir(path.join(repoPath, "packages", "api"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "api", "package.json"), + JSON.stringify({ name: "api", scripts: { start: "node index.js" } }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + const webArea = result.areas?.find((a) => a.name === "web"); + expect(webArea).toBeDefined(); + expect(webArea?.scripts?.build).toBe("next build"); + expect(webArea?.scripts?.test).toBe("jest"); + expect(webArea?.hasTsConfig).toBe(true); + }); + + it("reads scripts from heuristic area with package.json", async () => { + const repoPath = await makeTmpDir(); + await fs.mkdir(path.join(repoPath, "frontend"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "frontend", "package.json"), + JSON.stringify({ name: "frontend", scripts: { build: "vite build", test: "vitest" } }) + ); + await fs.writeFile(path.join(repoPath, "frontend", "tsconfig.json"), "{}"); + + const result = await analyzeRepo(repoPath); + const frontendArea = result.areas?.find((a) => a.name === "frontend"); + expect(frontendArea).toBeDefined(); + expect(frontendArea?.scripts?.build).toBe("vite build"); + expect(frontendArea?.hasTsConfig).toBe(true); + }); + + it("heuristic area without package.json has undefined scripts", async () => { + const repoPath = await makeTmpDir(); + await fs.mkdir(path.join(repoPath, "frontend"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "frontend", "index.ts"), "export {};"); + + const result = await analyzeRepo(repoPath); + const frontendArea = result.areas?.find((a) => a.name === "frontend"); + expect(frontendArea).toBeDefined(); + expect(frontendArea?.scripts).toBeUndefined(); + expect(frontendArea?.hasTsConfig).toBeUndefined(); + }); + + it("rejects config areas with path traversal", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ + areas: [ + { name: "escape", applyTo: "../../etc/**" }, + { name: "safe", applyTo: "src/**" } + ] + }) + ); + const result = await analyzeRepo(repoPath); + const areaNames = (result.areas ?? []).map((a) => a.name); + expect(areaNames).not.toContain("escape"); + expect(areaNames).toContain("safe"); + }); + + it("enriches config areas with scripts and hasTsConfig", async () => { + const repoPath = await makeTmpDir(); + await fs.mkdir(path.join(repoPath, "api"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "api", "package.json"), + JSON.stringify({ name: "api", scripts: { build: "tsc", test: "jest" } }) + ); + await fs.writeFile(path.join(repoPath, "api", "tsconfig.json"), "{}"); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ areas: [{ name: "API", applyTo: "api/**" }] }) + ); + const result = await analyzeRepo(repoPath); + const apiArea = result.areas?.find((a) => a.name === "API"); + expect(apiArea).toBeDefined(); + expect(apiArea?.source).toBe("config"); + expect(apiArea?.scripts?.build).toBe("tsc"); + expect(apiArea?.hasTsConfig).toBe(true); + }); + + it("detects C++ language from CMakeLists.txt", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "CMakeLists.txt"), + "cmake_minimum_required(VERSION 3.20)" + ); + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C++"); + }); + + it("detects C++ language from moz.build", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "moz.build"), "DIRS += ['dom']"); + const result = await analyzeRepo(repoPath); + expect(result.languages).toContain("C++"); + }); + + it("detects Turborepo overlay on npm workspaces", async () => { + const repoPath = await makeTmpDir(); + const packageJson = { name: "root", workspaces: ["packages/*"] }; + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify(packageJson)); + await fs.writeFile(path.join(repoPath, "turbo.json"), JSON.stringify({ pipeline: {} })); + await fs.mkdir(path.join(repoPath, "packages", "web"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "web", "package.json"), + JSON.stringify({ name: "web" }) + ); + await fs.mkdir(path.join(repoPath, "packages", "api"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "api", "package.json"), + JSON.stringify({ name: "api" }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.workspaceType).toBe("turborepo"); + expect(result.isMonorepo).toBe(true); + expect(result.apps?.length).toBe(2); + }); + + it("detects Bazel workspace with MODULE.bazel", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "MODULE.bazel"), 'module(name = "myproject")'); + + await fs.mkdir(path.join(repoPath, "server"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "server", "BUILD"), 'java_binary(name = "server")'); + await fs.mkdir(path.join(repoPath, "client"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "client", "BUILD.bazel"), + 'java_binary(name = "client")' + ); + // Dir without BUILD file should be skipped + await fs.mkdir(path.join(repoPath, "docs"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "docs", "README.md"), "# docs"); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("bazel"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["client", "server"]); + const clientApp = result.apps?.find((a) => a.name === "client"); + expect(clientApp?.manifestPath).toBe(path.join(repoPath, "client", "BUILD.bazel")); + expect(clientApp?.ecosystem).toBeUndefined(); + const serverApp = result.apps?.find((a) => a.name === "server"); + expect(serverApp?.manifestPath).toBe(path.join(repoPath, "server", "BUILD")); + }); + + it("detects Bazel workspace with WORKSPACE.bazel file", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "WORKSPACE.bazel"), 'workspace(name = "myproject")'); + + await fs.mkdir(path.join(repoPath, "lib"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "lib", "BUILD"), 'cc_library(name = "lib")'); + await fs.mkdir(path.join(repoPath, "bin"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "bin", "BUILD"), 'cc_binary(name = "bin")'); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("bazel"); + expect(result.packageManager).toBe("bazel"); + }); + + it("prioritizes Nx workspaceType for JS workspaces when nx.json exists", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "nx.json"), JSON.stringify({ npmScope: "myorg" })); + await fs.writeFile( + path.join(repoPath, "package.json"), + JSON.stringify({ name: "root", workspaces: ["packages/*"] }) + ); + await fs.mkdir(path.join(repoPath, "packages", "app"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "app", "package.json"), + JSON.stringify({ name: "app" }) + ); + await fs.mkdir(path.join(repoPath, "packages", "lib"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "packages", "lib", "package.json"), + JSON.stringify({ name: "lib" }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("nx"); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["app", "lib"]); + }); + + it("detects Nx workspace with project.json files", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "nx.json"), JSON.stringify({ npmScope: "myorg" })); + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify({ name: "root" })); + + await fs.mkdir(path.join(repoPath, "apps", "web"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "apps", "web", "project.json"), + JSON.stringify({ name: "web", projectType: "application" }) + ); + await fs.writeFile( + path.join(repoPath, "apps", "web", "package.json"), + JSON.stringify({ name: "web" }) + ); + await fs.writeFile(path.join(repoPath, "apps", "web", "tsconfig.json"), "{}"); + + await fs.mkdir(path.join(repoPath, "libs", "shared"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, "libs", "shared", "project.json"), + JSON.stringify({ name: "shared", projectType: "library" }) + ); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("nx"); + expect(result.apps?.length).toBe(2); + expect(result.apps?.map((a) => a.name).sort()).toEqual(["shared", "web"]); + const webApp = result.apps?.find((a) => a.name === "web"); + expect(webApp?.ecosystem).toBe("node"); + expect(webApp?.hasTsConfig).toBe(true); + }); + + it("detects Pants workspace with pants.toml and BUILD files", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile(path.join(repoPath, "pants.toml"), '[GLOBAL]\npants_version = "2.18.0"'); + + await fs.mkdir(path.join(repoPath, "src"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "src", "BUILD"), "python_sources()"); + await fs.writeFile(path.join(repoPath, "src", "pyproject.toml"), "[project]"); + + await fs.mkdir(path.join(repoPath, "tests"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "tests", "BUILD.pants"), "python_tests()"); + + const result = await analyzeRepo(repoPath); + expect(result.isMonorepo).toBe(true); + expect(result.workspaceType).toBe("pants"); + expect(result.apps?.length).toBe(2); + expect(result.packageManager).toBe("pants"); + const srcApp = result.apps?.find((a) => a.name === "src"); + expect(srcApp?.ecosystem).toBe("python"); + expect(srcApp?.manifestPath).toBe(path.join(repoPath, "src", "BUILD")); + const testsApp = result.apps?.find((a) => a.name === "tests"); + expect(testsApp?.manifestPath).toBe(path.join(repoPath, "tests", "BUILD.pants")); + expect(testsApp?.ecosystem).toBeUndefined(); + }); + + it("smart fallback detects areas in large repos", async () => { + const repoPath = await makeTmpDir(); + + // Create 12+ top-level dirs to trigger fallback (threshold: >10 dirs, <3 areas) + // Use names NOT in AREA_HEURISTIC_DIRS so heuristics won't find them + const dirs = [ + "gfx", + "netwerk", + "ipc", + "intl", + "caps", + "chrome", + "widget", + "accessible", + "parser", + "image", + "hal", + "uriloader" + ]; + for (const dir of dirs) { + await fs.mkdir(path.join(repoPath, dir), { recursive: true }); + // Add enough children (>= 3) and code files/manifests + await fs.writeFile(path.join(repoPath, dir, "moz.build"), "DIRS += []"); + await fs.writeFile(path.join(repoPath, dir, "README.md"), `# ${dir}`); + await fs.writeFile(path.join(repoPath, dir, "main.cpp"), "int main() {}"); + } + + const result = await analyzeRepo(repoPath); + const areaNames = (result.areas ?? []).map((a) => a.name); + // Fallback should detect these non-standard dirs + expect(areaNames).toContain("gfx"); + expect(areaNames).toContain("netwerk"); + expect(areaNames).toContain("ipc"); + expect(areaNames).toContain("intl"); + expect(areaNames).toContain("caps"); + // Verify we got a good number of areas + expect(result.areas?.length).toBeGreaterThanOrEqual(10); + }); + + it("smart fallback skips hidden dirs and known skip dirs", async () => { + const repoPath = await makeTmpDir(); + + // Create enough dirs to trigger fallback + const contentDirs = [ + "aaa", + "bbb", + "ccc", + "ddd", + "eee", + "fff", + "ggg", + "hhh", + "iii", + "jjj", + "kkk" + ]; + for (const dir of contentDirs) { + await fs.mkdir(path.join(repoPath, dir), { recursive: true }); + await fs.writeFile(path.join(repoPath, dir, "index.ts"), "export {};"); + await fs.writeFile(path.join(repoPath, dir, "util.ts"), "export {};"); + await fs.writeFile(path.join(repoPath, dir, "types.ts"), "export {};"); + } + + // These should be skipped + await fs.mkdir(path.join(repoPath, ".hidden"), { recursive: true }); + await fs.writeFile(path.join(repoPath, ".hidden", "file.ts"), "export {};"); + await fs.mkdir(path.join(repoPath, "node_modules"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "node_modules", "pkg.js"), ""); + await fs.mkdir(path.join(repoPath, "third_party"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "third_party", "lib.c"), ""); + + const result = await analyzeRepo(repoPath); + const areaNames = (result.areas ?? []).map((a) => a.name); + expect(areaNames).not.toContain(".hidden"); + expect(areaNames).not.toContain("node_modules"); + expect(areaNames).not.toContain("third_party"); + }); + + it("fallback does not trigger for small repos", async () => { + const repoPath = await makeTmpDir(); + + // Only 3 top-level dirs — should NOT trigger fallback + await fs.mkdir(path.join(repoPath, "custom1"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "custom1", "main.py"), "print('hi')"); + await fs.mkdir(path.join(repoPath, "custom2"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "custom2", "main.py"), "print('hi')"); + await fs.mkdir(path.join(repoPath, "custom3"), { recursive: true }); + await fs.writeFile(path.join(repoPath, "custom3", "main.py"), "print('hi')"); + + const result = await analyzeRepo(repoPath); + const areaNames = (result.areas ?? []).map((a) => a.name); + // custom1, custom2, custom3 are not in heuristic list and repo is small + expect(areaNames).not.toContain("custom1"); + }); +}); + +describe("loadPrimerConfig", () => { + const tmpDirs: string[] = []; + + async function makeTmpDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-config-")); + tmpDirs.push(dir); + return dir; + } + + afterEach(async () => { + for (const dir of tmpDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + tmpDirs.length = 0; + }); + + it("returns undefined when no config exists", async () => { + const repoPath = await makeTmpDir(); + expect(await loadPrimerConfig(repoPath)).toBeUndefined(); + }); + + it("loads config from repo root", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ areas: [{ name: "api", applyTo: "src/api/**" }] }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.areas?.length).toBe(1); + expect(config?.areas?.[0].name).toBe("api"); + }); + + it("loads config from .github/", async () => { + const repoPath = await makeTmpDir(); + await fs.mkdir(path.join(repoPath, ".github"), { recursive: true }); + await fs.writeFile( + path.join(repoPath, ".github", "primer.config.json"), + JSON.stringify({ areas: [{ name: "web", applyTo: ["web/**"] }] }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.areas?.length).toBe(1); + }); + + it("ignores malformed areas (missing name or applyTo)", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ + areas: [ + { name: "good", applyTo: "src/**" }, + { description: "no name" }, + { name: "no-apply" }, + "not-an-object" + ] + }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.areas?.length).toBe(1); + expect(config?.areas?.[0].name).toBe("good"); + }); + + it("rejects applyTo patterns with path traversal segments", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ + areas: [ + { name: "escape", applyTo: "../../etc/**" }, + { name: "good", applyTo: "src/**" } + ] + }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.areas?.length).toBe(1); + expect(config?.areas?.[0].name).toBe("good"); + }); + + it("returns undefined for non-array areas", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ areas: "oops" }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config).toBeUndefined(); + }); + + it("parses policies array from config", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ + areas: [{ name: "api", applyTo: "src/api/**" }], + policies: ["./custom-policy.json", "@org/policy-pkg"] + }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.policies).toEqual(["./custom-policy.json", "@org/policy-pkg"]); + expect(config?.areas).toHaveLength(1); + }); + + it("filters out non-string and empty policy entries", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ + policies: ["valid", 42, "", " ", null, "also-valid"] + }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.policies).toEqual(["valid", "also-valid"]); + }); + + it("returns undefined policies when field is absent", async () => { + const repoPath = await makeTmpDir(); + await fs.writeFile( + path.join(repoPath, "primer.config.json"), + JSON.stringify({ areas: [{ name: "web", applyTo: "web/**" }] }) + ); + const config = await loadPrimerConfig(repoPath); + expect(config?.policies).toBeUndefined(); + }); +}); + +describe("sanitizeAreaName", () => { + it("lowercases and replaces non-alphanumeric chars", () => { + expect(sanitizeAreaName("My App")).toBe("my-app"); + expect(sanitizeAreaName("frontend/api")).toBe("frontend-api"); + }); + + it("collapses multiple dashes", () => { + expect(sanitizeAreaName("my--app---name")).toBe("my-app-name"); + }); + + it("strips leading and trailing dashes", () => { + expect(sanitizeAreaName("-hello-")).toBe("hello"); + expect(sanitizeAreaName("@scope/pkg")).toBe("scope-pkg"); + }); + + it("returns 'unnamed' for empty result", () => { + expect(sanitizeAreaName("@#$")).toBe("unnamed"); + expect(sanitizeAreaName("")).toBe("unnamed"); + expect(sanitizeAreaName("---")).toBe("unnamed"); + }); +}); + +describe("buildAreaFrontmatter", () => { + it("generates frontmatter with single applyTo", () => { + const area: Area = { name: "api", applyTo: "api/**", source: "auto" }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain('applyTo: "api/**"'); + expect(fm).toContain('description: "Use when working on api"'); + expect(fm).toMatch(/^---\n/u); + expect(fm).toMatch(/\n---$/u); + }); + + it("generates frontmatter with array applyTo", () => { + const area: Area = { name: "web", applyTo: ["src/web/**", "public/**"], source: "auto" }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain('applyTo: ["src/web/**", "public/**"]'); + }); + + it("includes description when provided", () => { + const area: Area = { + name: "ui", + applyTo: "ui/**", + description: "React components", + source: "config" + }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain("Use when working on ui. React components"); + }); + + it("escapes quotes in description", () => { + const area: Area = { + name: "api", + applyTo: "api/**", + description: 'uses "REST" style', + source: "config" + }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain('uses \\"REST\\" style'); + expect(fm).not.toMatch(/[^\\]"REST"/u); + }); + + it("escapes quotes in applyTo patterns", () => { + const area: Area = { name: "test", applyTo: 'src/"spec"/**', source: "auto" }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain('src/\\"spec\\"/**'); + }); + + it("escapes backslashes in description", () => { + const area: Area = { + name: "api", + applyTo: "api/**", + description: "path is C:\\Users", + source: "config" + }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain("C:\\\\Users"); + }); + + it("escapes newlines and tabs in description", () => { + const area: Area = { + name: "api", + applyTo: "api/**", + description: "line1\nline2\ttab", + source: "config" + }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain("line1\\nline2\\ttab"); + expect(fm).not.toContain("\n" + "line2"); + }); + + it("strips null bytes from description", () => { + const area: Area = { + name: "api", + applyTo: "api/**", + description: "clean\0value", + source: "config" + }; + const fm = buildAreaFrontmatter(area); + expect(fm).toContain("cleanvalue"); + expect(fm).not.toContain("\0"); + }); +}); + +describe("buildAreaInstructionContent", () => { + it("wraps frontmatter and body", () => { + const area: Area = { name: "api", applyTo: "api/**", source: "auto" }; + const content = buildAreaInstructionContent(area, "# API instructions\nUse REST."); + expect(content).toMatch(/^---\n/u); + expect(content).toContain("# API instructions"); + expect(content).toContain("Use REST."); + expect(content).toMatch(/\n$/u); + }); +}); + +describe("areaInstructionPath", () => { + it("returns path under .github/instructions/", () => { + const area: Area = { name: "My App", applyTo: "my-app/**", source: "auto" }; + const p = areaInstructionPath("/repo", area); + expect(p).toContain(".github"); + expect(p).toContain("instructions"); + expect(p).toContain("my-app.instructions.md"); + }); + + it("sanitizes area name for filename", () => { + const area: Area = { name: "@scope/pkg", applyTo: "pkg/**", source: "config" }; + const p = areaInstructionPath("/repo", area); + expect(p).toContain("scope-pkg.instructions.md"); + }); +}); + +describe("writeAreaInstruction", () => { + const tmpDirs: string[] = []; + + async function makeTmpDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-write-")); + tmpDirs.push(dir); + return dir; + } + + afterEach(async () => { + for (const dir of tmpDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + } + tmpDirs.length = 0; + }); + + it("returns empty status for blank body", async () => { + const repoPath = await makeTmpDir(); + const area: Area = { name: "api", applyTo: "api/**", source: "auto" }; + const result = await writeAreaInstruction(repoPath, area, " \n "); + expect(result.status).toBe("empty"); + // File should not be created + await expect(fs.access(result.filePath)).rejects.toThrow(); + }); + + it("writes file for new area", async () => { + const repoPath = await makeTmpDir(); + const area: Area = { name: "api", applyTo: "api/**", source: "auto" }; + const result = await writeAreaInstruction(repoPath, area, "# API guide"); + expect(result.status).toBe("written"); + const content = await fs.readFile(result.filePath, "utf8"); + expect(content).toContain("# API guide"); + expect(content).toContain("applyTo:"); + expect(result.filePath).toContain("api.instructions.md"); + }); + + it("skips when file exists and force is false", async () => { + const repoPath = await makeTmpDir(); + const area: Area = { name: "api", applyTo: "api/**", source: "auto" }; + // Write first time + await writeAreaInstruction(repoPath, area, "# Original"); + // Try again without force + const result = await writeAreaInstruction(repoPath, area, "# Updated"); + expect(result.status).toBe("skipped"); + // Content should be unchanged + const content = await fs.readFile(result.filePath, "utf8"); + expect(content).toContain("# Original"); + }); + + it("overwrites when force is true", async () => { + const repoPath = await makeTmpDir(); + const area: Area = { name: "api", applyTo: "api/**", source: "auto" }; + await writeAreaInstruction(repoPath, area, "# Original"); + const result = await writeAreaInstruction(repoPath, area, "# Updated", true); + expect(result.status).toBe("written"); + const content = await fs.readFile(result.filePath, "utf8"); + expect(content).toContain("# Updated"); + expect(content).not.toContain("# Original"); + }); +}); diff --git a/src/services/__tests__/boundaries.test.ts b/src/services/__tests__/boundaries.test.ts new file mode 100644 index 0000000..999ae9c --- /dev/null +++ b/src/services/__tests__/boundaries.test.ts @@ -0,0 +1,200 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { safeWriteFile } from "../../utils/fs"; +import { deriveFileStatus, shouldLog } from "../../utils/output"; +import { GITHUB_REPO_RE, AZURE_REPO_RE } from "../../utils/repo"; +import { sanitizeError } from "../batch"; + +// ── sanitizeError ── + +describe("sanitizeError", () => { + it("scrubs x-access-token credentials", () => { + const raw = + "fatal: could not read from https://x-access-token:ghs_abc123@github.com/owner/repo"; + expect(sanitizeError(raw)).not.toContain("ghs_abc123"); + expect(sanitizeError(raw)).toContain("***@"); + }); + + it("scrubs pat credentials", () => { + const raw = "https://pat:my-secret-pat@dev.azure.com/org/project"; + expect(sanitizeError(raw)).not.toContain("my-secret-pat"); + expect(sanitizeError(raw)).toContain("***@"); + }); + + it("scrubs generic https credentials as catch-all", () => { + const raw = "https://user:token123@example.com/repo"; + expect(sanitizeError(raw)).not.toContain("token123"); + expect(sanitizeError(raw)).toContain("https://***@"); + }); + + it("leaves messages without tokens untouched", () => { + const raw = "Connection timed out after 30s"; + expect(sanitizeError(raw)).toBe(raw); + }); + + it("scrubs multiple tokens in one message", () => { + const raw = "cloned https://x-access-token:a@gh.com then pushed to https://pat:b@dev.azure.com"; + const result = sanitizeError(raw); + expect(result).not.toContain(":a@"); + expect(result).not.toContain(":b@"); + }); +}); + +// ── deriveFileStatus ── + +describe("deriveFileStatus", () => { + it("returns success when all files wrote", () => { + const { ok, status } = deriveFileStatus([{ action: "wrote" }, { action: "wrote" }]); + expect(ok).toBe(true); + expect(status).toBe("success"); + }); + + it("returns success for empty files array", () => { + const { ok, status } = deriveFileStatus([]); + expect(ok).toBe(true); + expect(status).toBe("success"); + }); + + it("returns partial when mixed wrote and skipped", () => { + const { ok, status } = deriveFileStatus([{ action: "wrote" }, { action: "skipped" }]); + expect(ok).toBe(true); + expect(status).toBe("partial"); + }); + + it("returns noop when all files skipped", () => { + const { ok, status } = deriveFileStatus([{ action: "skipped" }, { action: "skipped" }]); + expect(ok).toBe(true); + expect(status).toBe("noop"); + }); +}); + +// ── shouldLog ── + +describe("shouldLog", () => { + it("returns true with no flags", () => { + expect(shouldLog({})).toBe(true); + }); + + it("returns false with json", () => { + expect(shouldLog({ json: true })).toBe(false); + }); + + it("returns false with quiet", () => { + expect(shouldLog({ quiet: true })).toBe(false); + }); + + it("returns false with both", () => { + expect(shouldLog({ json: true, quiet: true })).toBe(false); + }); +}); + +// ── GITHUB_REPO_RE / AZURE_REPO_RE ── + +describe("GITHUB_REPO_RE", () => { + it("matches valid owner/name", () => { + expect(GITHUB_REPO_RE.test("owner/repo")).toBe(true); + expect(GITHUB_REPO_RE.test("my-org/my.repo")).toBe(true); + expect(GITHUB_REPO_RE.test("a/b")).toBe(true); + }); + + it("rejects path traversal", () => { + expect(GITHUB_REPO_RE.test("../evil/repo")).toBe(false); + expect(GITHUB_REPO_RE.test("owner/../etc")).toBe(false); + }); + + it("rejects dot-only segments", () => { + expect(GITHUB_REPO_RE.test("../repo")).toBe(false); + expect(GITHUB_REPO_RE.test("owner/..")).toBe(false); + expect(GITHUB_REPO_RE.test("./repo")).toBe(false); + expect(GITHUB_REPO_RE.test("owner/.")).toBe(false); + }); + + it("allows dotfile-prefixed segments", () => { + expect(GITHUB_REPO_RE.test(".github/repo")).toBe(true); + expect(GITHUB_REPO_RE.test("owner/.github")).toBe(true); + }); + + it("rejects empty segments", () => { + expect(GITHUB_REPO_RE.test("/repo")).toBe(false); + expect(GITHUB_REPO_RE.test("owner/")).toBe(false); + }); + + it("rejects triple-segment", () => { + expect(GITHUB_REPO_RE.test("org/project/repo")).toBe(false); + }); + + it("rejects whitespace and special chars", () => { + expect(GITHUB_REPO_RE.test("ow ner/repo")).toBe(false); + expect(GITHUB_REPO_RE.test("owner/re po")).toBe(false); + expect(GITHUB_REPO_RE.test("owner/repo;echo")).toBe(false); + }); +}); + +describe("AZURE_REPO_RE", () => { + it("matches valid org/project/repo", () => { + expect(AZURE_REPO_RE.test("my-org/my-project/my-repo")).toBe(true); + }); + + it("rejects path traversal", () => { + expect(AZURE_REPO_RE.test("../evil/project/repo")).toBe(false); + expect(AZURE_REPO_RE.test("org/../etc/repo")).toBe(false); + }); + + it("rejects dot-only segments", () => { + expect(AZURE_REPO_RE.test("../project/repo")).toBe(false); + expect(AZURE_REPO_RE.test("org/../repo")).toBe(false); + expect(AZURE_REPO_RE.test("org/project/..")).toBe(false); + expect(AZURE_REPO_RE.test("org/./repo")).toBe(false); + }); + + it("rejects two-segment (GitHub format)", () => { + expect(AZURE_REPO_RE.test("owner/repo")).toBe(false); + }); + + it("rejects four-segment", () => { + expect(AZURE_REPO_RE.test("a/b/c/d")).toBe(false); + }); +}); + +// ── safeWriteFile symlink rejection ── + +describe("safeWriteFile symlink", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-symlink-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("refuses to write through a symlink", async () => { + const realFile = path.join(tmpDir, "real.txt"); + const linkFile = path.join(tmpDir, "link.txt"); + await fs.writeFile(realFile, "original", "utf8"); + await fs.symlink(realFile, linkFile); + + const { wrote, reason } = await safeWriteFile(linkFile, "malicious", false); + expect(wrote).toBe(false); + expect(reason).toBe("symlink"); + // Ensure original file was not modified + expect(await fs.readFile(realFile, "utf8")).toBe("original"); + }); + + it("refuses to write through a symlink even with force", async () => { + const realFile = path.join(tmpDir, "real.txt"); + const linkFile = path.join(tmpDir, "link.txt"); + await fs.writeFile(realFile, "original", "utf8"); + await fs.symlink(realFile, linkFile); + + const { wrote, reason } = await safeWriteFile(linkFile, "malicious", true); + expect(wrote).toBe(false); + expect(reason).toBe("symlink"); + expect(await fs.readFile(realFile, "utf8")).toBe("original"); + }); +}); diff --git a/src/services/__tests__/cachePath.test.ts b/src/services/__tests__/cachePath.test.ts new file mode 100644 index 0000000..063419e --- /dev/null +++ b/src/services/__tests__/cachePath.test.ts @@ -0,0 +1,42 @@ +import os from "os"; +import path from "path"; + +import { describe, expect, it } from "vitest"; + +import { validateCachePath } from "../../utils/fs"; + +const cacheRoot = path.join(os.tmpdir(), "primer-cache"); + +describe("validateCachePath", () => { + it("returns resolved path for normal segments", () => { + const result = validateCachePath(cacheRoot, "owner", "repo"); + expect(result).toBe(path.resolve(cacheRoot, "owner", "repo")); + }); + + it("throws on path traversal via ..", () => { + expect(() => validateCachePath(cacheRoot, "..", "..", "etc")).toThrow( + "escapes cache directory" + ); + }); + + it("throws on absolute path segment that escapes", () => { + const escaping = path.resolve(cacheRoot, "..", "..", "etc"); + expect(() => validateCachePath(cacheRoot, escaping)).toThrow("escapes cache directory"); + }); + + it("allows the cache root itself", () => { + const result = validateCachePath(cacheRoot); + expect(result).toBe(path.resolve(cacheRoot)); + }); + + it("allows nested paths within cache root", () => { + const result = validateCachePath(cacheRoot, "org", "project", "repo"); + expect(result).toBe(path.resolve(cacheRoot, "org", "project", "repo")); + }); + + it("throws when segment contains .. to escape", () => { + expect(() => validateCachePath(cacheRoot, "owner", "repo", "..", "..", "..", "etc")).toThrow( + "escapes cache directory" + ); + }); +}); diff --git a/src/services/__tests__/cli.test.ts b/src/services/__tests__/cli.test.ts new file mode 100644 index 0000000..ddc4f42 --- /dev/null +++ b/src/services/__tests__/cli.test.ts @@ -0,0 +1,88 @@ +import type { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +import { withGlobalOpts } from "../../cli"; + +describe("withGlobalOpts", () => { + function buildFakeCommand(globalOpts: Record): Command { + return { optsWithGlobals: () => globalOpts } as unknown as Command; + } + + it("merges --json from program into command options", async () => { + const handler = + vi.fn< + (path: string, opts: { force?: boolean; json?: boolean; quiet?: boolean }) => Promise + >(); + const wrapped = withGlobalOpts(handler); + + const localOpts = { force: true }; + const cmd = buildFakeCommand({ json: true, quiet: false }); + + await wrapped("some/path", localOpts, cmd); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0]).toBe("some/path"); + expect(handler.mock.calls[0][1]).toEqual({ force: true, json: true, quiet: false }); + }); + + it("merges --quiet from program into command options", async () => { + const handler = vi.fn<(opts: { json?: boolean; quiet?: boolean }) => Promise>(); + const wrapped = withGlobalOpts(handler); + + const localOpts = {}; + const cmd = buildFakeCommand({ json: false, quiet: true }); + + await wrapped(localOpts, cmd); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0]).toEqual({ json: false, quiet: true }); + }); + + it("does not pass the Command object to the handler", async () => { + const handler = vi.fn<(opts: Record) => Promise>(); + const wrapped = withGlobalOpts(handler); + + const localOpts = { custom: "value" }; + const cmd = buildFakeCommand({ json: true, quiet: true }); + + await wrapped(localOpts, cmd); + + expect(handler).toHaveBeenCalledOnce(); + // Only one arg (options), Command should be stripped + expect(handler.mock.calls[0]).toHaveLength(1); + }); + + it("overrides local json/quiet with global values", async () => { + const handler = vi.fn<(opts: { json?: boolean; quiet?: boolean }) => Promise>(); + const wrapped = withGlobalOpts(handler); + + // Local opts say json:false, but global says json:true + const localOpts = { json: false, quiet: false }; + const cmd = buildFakeCommand({ json: true, quiet: true }); + + await wrapped(localOpts, cmd); + + expect(handler.mock.calls[0][0]).toEqual({ json: true, quiet: true }); + }); + + it("works with variadic arguments (batch-style)", async () => { + const handler = + vi.fn< + ( + repos: string[], + opts: { provider?: string; json?: boolean; quiet?: boolean } + ) => Promise + >(); + const wrapped = withGlobalOpts(handler); + + const repos = ["owner/a", "owner/b"]; + const localOpts = { provider: "github" }; + const cmd = buildFakeCommand({ json: true, quiet: false }); + + await wrapped(repos, localOpts, cmd); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0]).toEqual(["owner/a", "owner/b"]); + expect(handler.mock.calls[0][1]).toEqual({ provider: "github", json: true, quiet: false }); + }); +}); diff --git a/src/services/__tests__/fs.test.ts b/src/services/__tests__/fs.test.ts new file mode 100644 index 0000000..e77c25f --- /dev/null +++ b/src/services/__tests__/fs.test.ts @@ -0,0 +1,371 @@ +import type { PathLike } from "fs"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ensureDir, safeWriteFile } from "../../utils/fs"; + +describe("ensureDir", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-fs-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates a directory that does not exist", async () => { + const target = path.join(tmpDir, "a", "b", "c"); + await ensureDir(target); + + const stat = await fs.stat(target); + expect(stat.isDirectory()).toBe(true); + }); + + it("does not throw if directory already exists", async () => { + const target = path.join(tmpDir, "existing"); + await fs.mkdir(target); + await expect(ensureDir(target)).resolves.toBeUndefined(); + }); +}); + +describe("safeWriteFile", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-fs-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes a new file", async () => { + const filePath = path.join(tmpDir, "test.txt"); + const result = await safeWriteFile(filePath, "hello", false); + + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("hello"); + expect(result.wrote).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it("skips existing file without force and reports reason", async () => { + const filePath = path.join(tmpDir, "test.txt"); + await fs.writeFile(filePath, "original"); + const result = await safeWriteFile(filePath, "new content", false); + + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("original"); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + }); + + it("overwrites existing file with force", async () => { + const filePath = path.join(tmpDir, "test.txt"); + await fs.writeFile(filePath, "original"); + const result = await safeWriteFile(filePath, "new content", true); + + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("new content"); + expect(result.wrote).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it("rejects symlink even with force", async () => { + const realFile = path.join(tmpDir, "real.txt"); + const symlink = path.join(tmpDir, "symlink.txt"); + await fs.writeFile(realFile, "original"); + await fs.symlink(realFile, symlink); + + const result = await safeWriteFile(symlink, "malicious content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + // Verify the original file was NOT modified + const content = await fs.readFile(realFile, "utf8"); + expect(content).toBe("original"); + }); + + it("rejects symlink without force", async () => { + const realFile = path.join(tmpDir, "real.txt"); + const symlink = path.join(tmpDir, "symlink.txt"); + await fs.writeFile(realFile, "original"); + await fs.symlink(realFile, symlink); + + const result = await safeWriteFile(symlink, "malicious content", false); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + }); + + it("rejects writes through symlinked parent directory", async () => { + const outsideDir = path.join(tmpDir, "outside"); + const symlinkParent = path.join(tmpDir, "linked"); + await fs.mkdir(outsideDir); + await fs.symlink(outsideDir, symlinkParent); + + const targetPath = path.join(symlinkParent, "blocked.txt"); + const result = await safeWriteFile(targetPath, "content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + await expect(fs.access(path.join(outsideDir, "blocked.txt"))).rejects.toThrow(); + }); + + it("rejects writes through symlinked ancestor with nested missing directories", async () => { + const outsideDir = path.join(tmpDir, "outside"); + const symlinkParent = path.join(tmpDir, "linked"); + await fs.mkdir(outsideDir); + await fs.symlink(outsideDir, symlinkParent); + + const targetPath = path.join(symlinkParent, "nested", "deeper", "blocked.txt"); + const result = await safeWriteFile(targetPath, "content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + await expect(fs.access(path.join(outsideDir, "nested"))).rejects.toThrow(); + }); + + it("rejects writes when closest existing ancestor is under a symlinked prefix", async () => { + const outsideDir = path.join(tmpDir, "outside"); + const existingDir = path.join(outsideDir, "existing"); + const symlinkParent = path.join(tmpDir, "linked"); + await fs.mkdir(existingDir, { recursive: true }); + await fs.symlink(outsideDir, symlinkParent); + + const targetPath = path.join(symlinkParent, "existing", "blocked.txt"); + const result = await safeWriteFile(targetPath, "content", true); + + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + await expect(fs.access(path.join(existingDir, "blocked.txt"))).rejects.toThrow(); + }); + + it("rejects symlink targets in win32 force mode", async () => { + const realFile = path.join(tmpDir, "real.txt"); + const symlink = path.join(tmpDir, "symlink.txt"); + await fs.writeFile(realFile, "original"); + await fs.symlink(realFile, symlink); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(symlink, "malicious content", true); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("symlink"); + const content = await fs.readFile(realFile, "utf8"); + expect(content).toBe("original"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("overwrites existing regular files in win32 force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "updated", true); + expect(result.wrote).toBe(true); + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("updated"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("returns exists for existing regular files in win32 non-force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "updated", false); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("original"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("creates missing files in win32 force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "missing.txt"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "created", true); + expect(result.wrote).toBe(true); + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("created"); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("does not replace directory targets in win32 force mode", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target-dir"); + await fs.mkdir(targetPath); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + try { + const result = await safeWriteFile(targetPath, "updated", true); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + const stat = await fs.stat(targetPath); + expect(stat.isDirectory()).toBe(true); + } finally { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("throws when win32 force replace cannot restore original file", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + const originalRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + let renameCallCount = 0; + renameSpy.mockImplementation(async (oldPath: PathLike, newPath: PathLike) => { + renameCallCount += 1; + if (renameCallCount === 2 || renameCallCount === 3) { + const error = new Error("EEXIST") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + return originalRename(oldPath, newPath); + }); + + try { + let thrownError: unknown; + try { + await safeWriteFile(targetPath, "updated", true); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + const message = (thrownError as Error).message; + expect(message).toContain("Failed to restore original file"); + const backupPath = message.split("backup retained at ")[1]; + expect(backupPath).toBeTruthy(); + + const backupContent = await fs.readFile(backupPath, "utf8"); + expect(backupContent).toBe("original"); + } finally { + renameSpy.mockRestore(); + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); + + it("restores original file when win32 force replace fails but rollback succeeds", async () => { + const canonicalTmpDir = await fs.realpath(tmpDir); + const targetPath = path.join(canonicalTmpDir, "target.txt"); + await fs.writeFile(targetPath, "original"); + + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!originalPlatformDescriptor) { + throw new Error("Unable to read process.platform descriptor"); + } + + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32" + }); + + const originalRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + let renameCallCount = 0; + renameSpy.mockImplementation(async (oldPath: PathLike, newPath: PathLike) => { + renameCallCount += 1; + if (renameCallCount === 2) { + const error = new Error("EEXIST") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + return originalRename(oldPath, newPath); + }); + + try { + const result = await safeWriteFile(targetPath, "updated", true); + expect(result.wrote).toBe(false); + expect(result.reason).toBe("exists"); + + const content = await fs.readFile(targetPath, "utf8"); + expect(content).toBe("original"); + + const files = await fs.readdir(canonicalTmpDir); + expect(files.some((file) => file.startsWith(".primer-backup-"))).toBe(false); + expect(files.some((file) => file.startsWith(".primer-tmp-"))).toBe(false); + } finally { + renameSpy.mockRestore(); + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + }); +}); diff --git a/src/services/__tests__/generator.test.ts b/src/services/__tests__/generator.test.ts new file mode 100644 index 0000000..993fa57 --- /dev/null +++ b/src/services/__tests__/generator.test.ts @@ -0,0 +1,129 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { RepoAnalysis } from "../analyzer"; +import { generateConfigs } from "../generator"; + +describe("generateConfigs", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-gen-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function makeAnalysis(overrides?: Partial): RepoAnalysis { + return { + path: tmpDir, + isGitRepo: false, + languages: ["TypeScript"], + frameworks: [], + ...overrides + }; + } + + it("generates valid mcp.json", async () => { + const analysis = makeAnalysis(); + const { files } = await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["mcp"], + force: false + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "mcp.json"), "utf8"); + const parsed = JSON.parse(content); + + expect(parsed.servers).toBeDefined(); + expect(parsed.servers.github).toBeDefined(); + expect(parsed.servers.filesystem).toBeDefined(); + expect(files.some((f) => f.action === "wrote")).toBe(true); + }); + + it("generates valid vscode settings with frameworks", async () => { + const analysis = makeAnalysis({ frameworks: ["React", "Next.js"] }); + await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["vscode"], + force: false + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "settings.json"), "utf8"); + const parsed = JSON.parse(content); + + expect(parsed["github.copilot.chat.codeGeneration.instructions"]).toBeDefined(); + expect(parsed["chat.mcp.enabled"]).toBe(true); + // Should mention frameworks in review instructions + const reviewText = parsed["github.copilot.chat.reviewSelection.instructions"][0].text; + expect(reviewText).toContain("React"); + expect(reviewText).toContain("Next.js"); + }); + + it("generates fallback review text when no frameworks", async () => { + const analysis = makeAnalysis({ frameworks: [] }); + await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["vscode"], + force: false + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "settings.json"), "utf8"); + const parsed = JSON.parse(content); + const reviewText = parsed["github.copilot.chat.reviewSelection.instructions"][0].text; + expect(reviewText).toContain("repo conventions"); + }); + + it("skips existing files without force", async () => { + await fs.mkdir(path.join(tmpDir, ".vscode"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, ".vscode", "mcp.json"), "original", "utf8"); + + const analysis = makeAnalysis(); + const { files } = await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["mcp"], + force: false + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "mcp.json"), "utf8"); + expect(content).toBe("original"); + expect(files.some((f) => f.action === "skipped")).toBe(true); + }); + + it("overwrites existing files with force", async () => { + await fs.mkdir(path.join(tmpDir, ".vscode"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, ".vscode", "mcp.json"), "original", "utf8"); + + const analysis = makeAnalysis(); + const { files } = await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: ["mcp"], + force: true + }); + + const content = await fs.readFile(path.join(tmpDir, ".vscode", "mcp.json"), "utf8"); + expect(content).not.toBe("original"); + expect(files.some((f) => f.action === "wrote")).toBe(true); + }); + + it("does nothing with empty selections", async () => { + const analysis = makeAnalysis(); + const { files } = await generateConfigs({ + repoPath: tmpDir, + analysis, + selections: [], + force: false + }); + + expect(files).toHaveLength(0); + }); +}); diff --git a/src/services/__tests__/git.test.ts b/src/services/__tests__/git.test.ts new file mode 100644 index 0000000..51adfdc --- /dev/null +++ b/src/services/__tests__/git.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { buildAuthedUrl } from "../git"; + +describe("buildAuthedUrl", () => { + it("adds github x-access-token to https URL", () => { + expect(buildAuthedUrl("https://github.com/owner/repo", "tok123", "github")).toBe( + "https://x-access-token:tok123@github.com/owner/repo" + ); + }); + + it("adds azure PAT to https URL", () => { + expect(buildAuthedUrl("https://dev.azure.com/org/project/_git/repo", "pat123", "azure")).toBe( + "https://pat:pat123@dev.azure.com/org/project/_git/repo" + ); + }); + + it("strips trailing slashes before adding auth", () => { + expect(buildAuthedUrl("https://github.com/owner/repo///", "tok", "github")).toBe( + "https://x-access-token:tok@github.com/owner/repo" + ); + }); + + it("replaces existing x-access-token auth", () => { + expect( + buildAuthedUrl("https://x-access-token:old@github.com/owner/repo", "new-tok", "github") + ).toBe("https://x-access-token:new-tok@github.com/owner/repo"); + }); + + it("replaces existing PAT auth for azure", () => { + expect(buildAuthedUrl("https://pat:old@dev.azure.com/repo", "new-pat", "azure")).toBe( + "https://pat:new-pat@dev.azure.com/repo" + ); + }); + + it("returns non-https URLs unchanged", () => { + expect(buildAuthedUrl("git@github.com:owner/repo.git", "tok", "github")).toBe( + "git@github.com:owner/repo.git" + ); + }); + + it("handles whitespace in URL", () => { + expect(buildAuthedUrl(" https://github.com/owner/repo ", "tok", "github")).toBe( + "https://x-access-token:tok@github.com/owner/repo" + ); + }); +}); diff --git a/src/services/__tests__/instructions.test.ts b/src/services/__tests__/instructions.test.ts new file mode 100644 index 0000000..b5833bc --- /dev/null +++ b/src/services/__tests__/instructions.test.ts @@ -0,0 +1,192 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { Area } from "../analyzer"; +import { + writeAreaInstruction, + buildAreaFrontmatter, + buildAreaInstructionContent, + areaInstructionPath +} from "../instructions"; + +describe("writeAreaInstruction", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-inst-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + const makeArea = (name: string, applyTo: string | string[] = "src/**/*.ts"): Area => ({ + name, + applyTo, + description: `Test area for ${name}`, + source: "config" + }); + + it("writes new area instruction file", async () => { + const area = makeArea("frontend"); + const body = "# Frontend Guidelines\n\nUse React conventions."; + + const result = await writeAreaInstruction(tmpDir, area, body, false); + + expect(result.status).toBe("written"); + expect(result.filePath).toBe(areaInstructionPath(tmpDir, area)); + + const content = await fs.readFile(result.filePath, "utf8"); + expect(content).toContain("# Frontend Guidelines"); + expect(content).toContain("applyTo:"); + }); + + it("returns empty status for empty body", async () => { + const area = makeArea("empty-area"); + const result = await writeAreaInstruction(tmpDir, area, " ", false); + + expect(result.status).toBe("empty"); + }); + + it("skips existing file without force", async () => { + const area = makeArea("backend"); + const filePath = areaInstructionPath(tmpDir, area); + + // Create the file first + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, "original content", "utf8"); + + const result = await writeAreaInstruction(tmpDir, area, "new content", false); + + expect(result.status).toBe("skipped"); + const content = await fs.readFile(filePath, "utf8"); + expect(content).toBe("original content"); + }); + + it("overwrites existing file with force", async () => { + const area = makeArea("backend"); + const filePath = areaInstructionPath(tmpDir, area); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, "original content", "utf8"); + + const result = await writeAreaInstruction(tmpDir, area, "new content", true); + + expect(result.status).toBe("written"); + const content = await fs.readFile(filePath, "utf8"); + expect(content).toContain("new content"); + }); + + it("rejects symlink even with force", async () => { + const area = makeArea("malicious"); + const filePath = areaInstructionPath(tmpDir, area); + const realFile = path.join(tmpDir, "real.md"); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(realFile, "original secure content", "utf8"); + await fs.symlink(realFile, filePath); + + const result = await writeAreaInstruction(tmpDir, area, "malicious content", true); + + expect(result.status).toBe("symlink"); + // Verify original file was NOT modified + const content = await fs.readFile(realFile, "utf8"); + expect(content).toBe("original secure content"); + }); +}); + +describe("buildAreaFrontmatter", () => { + it("builds frontmatter with single applyTo pattern", () => { + const area: Area = { + name: "tests", + applyTo: "**/*.test.ts", + description: "Testing area", + source: "config" + }; + + const frontmatter = buildAreaFrontmatter(area); + + expect(frontmatter).toContain('applyTo: "**/*.test.ts"'); + expect(frontmatter).toContain("description:"); + expect(frontmatter).toContain("tests"); + }); + + it("builds frontmatter with multiple applyTo patterns", () => { + const area: Area = { + name: "frontend", + applyTo: ["src/**/*.tsx", "src/**/*.css"], + description: "Frontend components", + source: "config" + }; + + const frontmatter = buildAreaFrontmatter(area); + + expect(frontmatter).toContain('["src/**/*.tsx", "src/**/*.css"]'); + }); + + it("escapes special characters in strings", () => { + const area: Area = { + name: "special", + applyTo: 'src/"test"/*.ts', + description: 'Area with "quotes"', + source: "config" + }; + + const frontmatter = buildAreaFrontmatter(area); + + // Should have escaped quotes + expect(frontmatter).toContain('\\"'); + // Should be valid YAML format + expect(frontmatter).toMatch(/^---\n/); + expect(frontmatter).toMatch(/\n---$/); + }); +}); + +describe("buildAreaInstructionContent", () => { + it("combines frontmatter and body with proper spacing", () => { + const area: Area = { + name: "api", + applyTo: "src/api/**/*.ts", + source: "config" + }; + const body = "# API Guidelines\n\nFollow REST conventions."; + + const content = buildAreaInstructionContent(area, body); + + expect(content).toMatch(/^---\n/); + expect(content).toMatch(/---\n\n# API Guidelines/); + expect(content).toContain("Follow REST conventions."); + expect(content).toMatch(/\n$/); + }); +}); + +describe("areaInstructionPath", () => { + it("generates correct path for area", () => { + const area: Area = { + name: "Frontend Components", + applyTo: "src/**/*.tsx", + source: "config" + }; + + const result = areaInstructionPath("/repo", area); + + expect(result).toBe( + path.join("/repo", ".github", "instructions", "frontend-components.instructions.md") + ); + }); + + it("sanitizes area name with special characters", () => { + const area: Area = { + name: "API/Backend (Core)", + applyTo: "src/api/**/*.ts", + source: "config" + }; + + const result = areaInstructionPath("/repo", area); + + expect(result).toMatch(/api-backend-core\.instructions\.md$/); + }); +}); diff --git a/src/services/__tests__/output.test.ts b/src/services/__tests__/output.test.ts new file mode 100644 index 0000000..8bd17ff --- /dev/null +++ b/src/services/__tests__/output.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + outputResult, + outputError, + createProgressReporter, + type CommandResult +} from "../../utils/output"; + +describe("outputResult", () => { + let stdoutSpy: ReturnType; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + }); + + it("writes JSON to stdout when json=true", () => { + const result: CommandResult<{ count: number }> = { + ok: true, + status: "success", + data: { count: 42 } + }; + outputResult(result, true); + expect(stdoutSpy).toHaveBeenCalledOnce(); + const written = stdoutSpy.mock.calls[0][0] as string; + expect(JSON.parse(written)).toEqual(result); + }); + + it("writes nothing when json=false", () => { + const result: CommandResult = { ok: true, status: "success" }; + outputResult(result, false); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); +}); + +describe("outputError", () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + const origExitCode = process.exitCode; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); + stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + process.exitCode = undefined; + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + process.exitCode = origExitCode; + }); + + it("writes JSON error to stdout when json=true", () => { + outputError("something broke", true); + expect(stdoutSpy).toHaveBeenCalledOnce(); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0] as string); + expect(parsed.ok).toBe(false); + expect(parsed.status).toBe("error"); + expect(parsed.errors).toEqual(["something broke"]); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("writes human error to stderr when json=false", () => { + outputError("something broke", false); + expect(stderrSpy).toHaveBeenCalledOnce(); + expect(stderrSpy.mock.calls[0][0]).toContain("something broke"); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + it("sets process.exitCode to 1", () => { + outputError("fail", false); + expect(process.exitCode).toBe(1); + }); +}); + +describe("createProgressReporter", () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it("human reporter writes to stderr", () => { + const reporter = createProgressReporter(false); + reporter.update("loading..."); + reporter.succeed("done!"); + reporter.fail("oops"); + reporter.done(); + expect(stderrSpy).toHaveBeenCalledTimes(3); + expect(stderrSpy.mock.calls[0][0]).toContain("loading..."); + expect(stderrSpy.mock.calls[1][0]).toContain("done!"); + expect(stderrSpy.mock.calls[2][0]).toContain("oops"); + }); + + it("silent reporter writes nothing", () => { + const reporter = createProgressReporter(true); + reporter.update("loading..."); + reporter.succeed("done!"); + reporter.fail("oops"); + reporter.done(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/__tests__/policy-adapter.test.ts b/src/services/__tests__/policy-adapter.test.ts new file mode 100644 index 0000000..8a1128a --- /dev/null +++ b/src/services/__tests__/policy-adapter.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; + +import { engineReportToReadiness } from "../policy/adapter"; +import type { EngineReport, Signal } from "../policy/types"; + +function makeSignal(overrides: Partial = {}): Signal { + return { + id: "test-signal", + kind: "file", + status: "detected", + label: "Test Signal", + origin: { addedBy: "test" }, + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + }, + ...overrides + }; +} + +function makeReport(overrides: Partial = {}): EngineReport { + return { + signals: [], + recommendations: [], + policyWarnings: [], + score: 1, + grade: "A", + pluginChain: ["builtin"], + ...overrides + }; +} + +describe("engineReportToReadiness", () => { + it("produces a valid ReadinessReport structure", () => { + const result = engineReportToReadiness(makeReport(), { repoPath: "/tmp/test" }); + expect(result.repoPath).toBe("/tmp/test"); + expect(result.isMonorepo).toBe(false); + expect(result.apps).toEqual([]); + expect(result.generatedAt).toBeDefined(); + expect(result.pillars).toHaveLength(9); + expect(result.levels).toHaveLength(5); + expect(result.criteria).toEqual([]); + expect(result.extras).toEqual([]); + expect(result.policies).toEqual({ chain: ["builtin"], criteriaCount: 0 }); + expect(result.engine).toBeDefined(); + expect(result.engine!.score).toBe(1); + expect(result.engine!.grade).toBe("A"); + }); + + it("maps signals with pillar metadata to criteria", () => { + const signal = makeSignal({ + id: "readme", + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }); + const result = engineReportToReadiness(makeReport({ signals: [signal] }), { + repoPath: "/tmp/test" + }); + expect(result.criteria).toHaveLength(1); + expect(result.criteria[0].id).toBe("readme"); + expect(result.criteria[0].pillar).toBe("documentation"); + expect(result.criteria[0].status).toBe("pass"); + expect(result.extras).toHaveLength(0); + }); + + it("maps signals without pillar metadata to extras", () => { + const signal = makeSignal({ + id: "agents-doc", + metadata: { checkStatus: "fail" } + }); + const result = engineReportToReadiness(makeReport({ signals: [signal] }), { + repoPath: "/tmp/test" + }); + expect(result.extras).toHaveLength(1); + expect(result.extras[0].id).toBe("agents-doc"); + expect(result.extras[0].status).toBe("fail"); + expect(result.criteria).toHaveLength(0); + }); + + it("computes pillar summaries from criteria", () => { + const signals = [ + makeSignal({ + id: "a", + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }), + makeSignal({ + id: "b", + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "fail" + } + }) + ]; + const result = engineReportToReadiness(makeReport({ signals }), { repoPath: "/tmp/test" }); + const docPillar = result.pillars.find((p) => p.id === "documentation"); + expect(docPillar).toBeDefined(); + expect(docPillar!.passed).toBe(1); + expect(docPillar!.total).toBe(2); + expect(docPillar!.passRate).toBe(0.5); + }); + + it("computes level summaries and achievedLevel", () => { + const signals = [ + makeSignal({ + id: "c1", + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }), + makeSignal({ + id: "c2", + metadata: { + pillar: "testing", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }) + ]; + const result = engineReportToReadiness(makeReport({ signals }), { repoPath: "/tmp/test" }); + expect(result.levels[0].level).toBe(1); + expect(result.levels[0].achieved).toBe(true); + expect(result.achievedLevel).toBe(1); + // Level 2 has no criteria → total=0 → not achieved + expect(result.levels[1].achieved).toBe(false); + }); + + it("skips skip-status criteria from totals", () => { + const signals = [ + makeSignal({ + id: "a", + metadata: { + pillar: "testing", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "skip" + } + }) + ]; + const result = engineReportToReadiness(makeReport({ signals }), { repoPath: "/tmp/test" }); + const testPillar = result.pillars.find((p) => p.id === "testing"); + expect(testPillar!.total).toBe(0); + expect(testPillar!.passRate).toBe(0); + }); + + it("respects passRateThreshold for level achievement", () => { + // 1 pass, 1 fail at level 1 → passRate 0.5 < default 0.8 + const signals = [ + makeSignal({ + id: "a", + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }), + makeSignal({ + id: "b", + metadata: { + pillar: "testing", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "fail" + } + }) + ]; + const result = engineReportToReadiness(makeReport({ signals }), { + repoPath: "/tmp/test", + passRateThreshold: 0.4 + }); + // With threshold at 0.4, level 1 should be achieved (passRate 0.5 >= 0.4) + expect(result.levels[0].achieved).toBe(true); + expect(result.achievedLevel).toBe(1); + }); + + it("includes policy chain metadata", () => { + const result = engineReportToReadiness( + makeReport({ pluginChain: ["builtin", "custom-policy"] }), + { repoPath: "/tmp/test" } + ); + expect(result.policies!.chain).toEqual(["builtin", "custom-policy"]); + }); + + it("blocks level 2 achievement when level 1 fails (cascading)", () => { + // Level 1: 1 pass + 1 fail → passRate 0.5 < default 0.8 → not achieved + // Level 2: 2 passes → passRate 1.0 ≥ 0.8, but level 2 must NOT be achieved + // because levels are sequential: failing level N blocks N+1 + const signals = [ + makeSignal({ + id: "l1a", + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }), + makeSignal({ + id: "l1b", + metadata: { + pillar: "testing", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "fail" + } + }), + makeSignal({ + id: "l2a", + metadata: { + pillar: "documentation", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }), + makeSignal({ + id: "l2b", + metadata: { + pillar: "testing", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + }) + ]; + const result = engineReportToReadiness(makeReport({ signals }), { repoPath: "/tmp/test" }); + const level1 = result.levels.find((l) => l.level === 1); + const level2 = result.levels.find((l) => l.level === 2); + expect(level1!.achieved).toBe(false); + expect(level2!.achieved).toBe(false); + expect(result.achievedLevel).toBe(0); + }); +}); diff --git a/src/services/__tests__/policy-compiler.test.ts b/src/services/__tests__/policy-compiler.test.ts new file mode 100644 index 0000000..1f0eee3 --- /dev/null +++ b/src/services/__tests__/policy-compiler.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from "vitest"; + +import type { PolicyConfig, ExtraDefinition } from "../policy"; +import { compilePolicyConfig } from "../policy/compiler"; +import type { PolicyContext, Signal } from "../policy/types"; +import type { ReadinessCriterion } from "../readiness"; +import { buildCriteria } from "../readiness"; + +// ─── Helpers ─── + +function makeCtx(): PolicyContext { + return { + repoPath: "/tmp/test", + rootFiles: ["package.json"], + cache: new Map() + }; +} + +const baseCriteria = buildCriteria(); + +// ─── compilePolicyConfig ─── + +describe("compilePolicyConfig", () => { + it("compiles a minimal policy with name only", () => { + const config: PolicyConfig = { name: "minimal" }; + const result = compilePolicyConfig(config, baseCriteria); + + expect(result.plugin.meta.name).toBe("minimal"); + expect(result.plugin.meta.sourceType).toBe("json"); + expect(result.plugin.meta.trust).toBe("safe-declarative"); + expect(result.disabledIds).toEqual([]); + expect(result.passRateThreshold).toBeUndefined(); + }); + + it("collects disabled criterion IDs", () => { + const config: PolicyConfig = { + name: "disabler", + criteria: { disable: ["lint-config", "readme"] } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.disabledIds).toEqual(["lint-config", "readme"]); + }); + + it("collects disabled extra IDs", () => { + const config: PolicyConfig = { + name: "disabler", + extras: { disable: ["agents-doc"] } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.disabledIds).toContain("agents-doc"); + }); + + it("combines criteria and extras disabled IDs", () => { + const config: PolicyConfig = { + name: "combined", + criteria: { disable: ["lint-config"] }, + extras: { disable: ["pr-template"] } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.disabledIds).toEqual(["lint-config", "pr-template"]); + }); + + it("extracts passRateThreshold", () => { + const config: PolicyConfig = { + name: "thresholder", + thresholds: { passRate: 0.9 } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.passRateThreshold).toBe(0.9); + }); + + it("creates afterDetect hook for metadata overrides", async () => { + const config: PolicyConfig = { + name: "overrider", + criteria: { + override: { + "lint-config": { title: "Custom Lint Title" } + } + } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.plugin.afterDetect).toBeDefined(); + + // Simulate signals and apply hook + const signals: Signal[] = [ + { + id: "lint-config", + kind: "file", + status: "detected", + label: "Original", + origin: { addedBy: "test" } + } + ]; + const patch = await result.plugin.afterDetect!(signals, makeCtx()); + expect(patch).toBeDefined(); + expect(patch!.modify).toHaveLength(1); + expect(patch!.modify![0].changes.label).toBe("Custom Lint Title"); + }); + + it("afterDetect hook produces metadata overrides for pillar/level/impact", async () => { + const config: PolicyConfig = { + name: "meta-overrider", + criteria: { + override: { + "lint-config": { pillar: "testing", level: 3, impact: "high" } + } + } + }; + const result = compilePolicyConfig(config, baseCriteria); + const signals: Signal[] = [ + { + id: "lint-config", + kind: "file", + status: "detected", + label: "Lint", + origin: { addedBy: "test" }, + metadata: { pillar: "style-validation", level: 1, checkStatus: "pass" } + } + ]; + const patch = await result.plugin.afterDetect!(signals, makeCtx()); + expect(patch).toBeDefined(); + expect(patch!.modify).toHaveLength(1); + const meta = patch!.modify![0].changes.metadata as Record; + expect(meta.pillar).toBe("testing"); + expect(meta.level).toBe(3); + expect(meta.impact).toBe("high"); + }); + + it("afterDetect hook skips non-existent signal IDs", async () => { + const config: PolicyConfig = { + name: "overrider", + criteria: { + override: { + "nonexistent-id": { title: "Nope" } + } + } + }; + const result = compilePolicyConfig(config, baseCriteria); + const signals: Signal[] = [ + { + id: "lint-config", + kind: "file", + status: "detected", + label: "Original", + origin: { addedBy: "test" } + } + ]; + const patch = await result.plugin.afterDetect!(signals, makeCtx()); + expect(patch).toBeUndefined(); + }); + + it("creates detectors and recommenders from criteria.add", () => { + const criterion: ReadinessCriterion = { + id: "custom-check", + title: "Custom Check", + pillar: "code-quality", + level: 1, + scope: "repo", + impact: "medium", + effort: "low", + check: async () => ({ status: "pass" }) + }; + const config: PolicyConfig = { + name: "adder", + criteria: { add: [criterion] } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.plugin.detectors).toHaveLength(1); + expect(result.plugin.detectors![0].id).toBe("custom-check"); + expect(result.plugin.recommenders).toHaveLength(1); + expect(result.plugin.recommenders![0].id).toBe("custom-check-rec"); + }); + + it("compiled detector emits a signal from a passing criterion", async () => { + const criterion: ReadinessCriterion = { + id: "my-criterion", + title: "My Title", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async () => ({ status: "pass", reason: "All good" }) + }; + const config: PolicyConfig = { + name: "checker", + criteria: { add: [criterion] } + }; + const result = compilePolicyConfig(config, baseCriteria); + const signal = await result.plugin.detectors![0].detect(makeCtx()); + const s = Array.isArray(signal) ? signal[0] : signal; + expect(s.id).toBe("my-criterion"); + expect(s.label).toBe("My Title"); + expect(s.origin.addedBy).toBe("compiled:my-criterion"); + expect(s.metadata).toHaveProperty("checkStatus", "pass"); + }); + + it("compiled recommender emits recommendation for failing criterion", async () => { + const signals: Signal[] = [ + { + id: "my-criterion", + kind: "file", + status: "detected", + label: "My Title", + reason: "Something failed", + origin: { addedBy: "compiled:my-criterion" }, + metadata: { checkStatus: "fail" } + } + ]; + const criterion: ReadinessCriterion = { + id: "my-criterion", + title: "My Title", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async () => ({ status: "fail", reason: "Something failed" }) + }; + const config: PolicyConfig = { + name: "checker", + criteria: { add: [criterion] } + }; + const result = compilePolicyConfig(config, baseCriteria); + const rec = await result.plugin.recommenders![0].recommend(signals, makeCtx()); + const r = Array.isArray(rec) ? rec[0] : rec; + expect(r.id).toBe("my-criterion-fix"); + expect(r.impact).toBe("high"); + }); + + it("compiled recommender uses runtime impact from signal.metadata (afterDetect override)", async () => { + // Simulate an afterDetect hook that overrides impact to "medium" in signal metadata + const signals: Signal[] = [ + { + id: "my-criterion", + kind: "file", + status: "detected", + label: "My Title", + reason: "Something failed", + origin: { addedBy: "compiled:my-criterion" }, + // afterDetect has patched impact to "medium" (down from original "high") + metadata: { checkStatus: "fail", impact: "medium" } + } + ]; + const criterion: ReadinessCriterion = { + id: "my-criterion", + title: "My Title", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", // original compile-time impact + effort: "low", + check: async () => ({ status: "fail" }) + }; + const config: PolicyConfig = { + name: "checker", + criteria: { add: [criterion] } + }; + const result = compilePolicyConfig(config, baseCriteria); + const rec = await result.plugin.recommenders![0].recommend(signals, makeCtx()); + const r = Array.isArray(rec) ? rec[0] : rec; + // Must use runtime impact "medium", not compile-time "high" + expect(r.impact).toBe("medium"); + }); + + it("compiled recommender returns empty for passing criterion", async () => { + const signals: Signal[] = [ + { + id: "my-criterion", + kind: "file", + status: "detected", + label: "My Title", + origin: { addedBy: "compiled:my-criterion" }, + metadata: { checkStatus: "pass" } + } + ]; + const criterion: ReadinessCriterion = { + id: "my-criterion", + title: "My Title", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async () => ({ status: "pass" }) + }; + const config: PolicyConfig = { + name: "checker", + criteria: { add: [criterion] } + }; + const result = compilePolicyConfig(config, baseCriteria); + const rec = await result.plugin.recommenders![0].recommend(signals, makeCtx()); + const recs = Array.isArray(rec) ? rec : [rec]; + expect(recs).toHaveLength(0); + }); + + it("has no detectors/recommenders/hooks when policy only disables", () => { + const config: PolicyConfig = { + name: "disabler-only", + criteria: { disable: ["lint-config"] } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.plugin.detectors).toBeUndefined(); + expect(result.plugin.recommenders).toBeUndefined(); + expect(result.plugin.afterDetect).toBeUndefined(); + }); + + it("creates detectors and recommenders from extras.add", () => { + const extra: ExtraDefinition = { + id: "custom-extra", + title: "Custom Extra", + check: async () => ({ status: "pass", reason: "OK" }) + }; + const config: PolicyConfig = { + name: "extra-adder", + extras: { add: [extra] } + }; + const result = compilePolicyConfig(config, baseCriteria); + expect(result.plugin.detectors).toHaveLength(1); + expect(result.plugin.detectors![0].id).toBe("custom-extra"); + expect(result.plugin.detectors![0].kind).toBe("custom"); + expect(result.plugin.recommenders).toHaveLength(1); + expect(result.plugin.recommenders![0].id).toBe("custom-extra-rec"); + }); + + it("compiled extra recommender emits low-impact recommendation on fail", async () => { + const signals: Signal[] = [ + { + id: "custom-extra", + kind: "custom", + status: "detected", + label: "Custom Extra", + reason: "Missing thing", + origin: { addedBy: "compiled:custom-extra" }, + metadata: { checkStatus: "fail" } + } + ]; + const extra: ExtraDefinition = { + id: "custom-extra", + title: "Custom Extra", + check: async () => ({ status: "fail", reason: "Missing thing" }) + }; + const config: PolicyConfig = { + name: "extra-adder", + extras: { add: [extra] } + }; + const result = compilePolicyConfig(config, baseCriteria); + const rec = await result.plugin.recommenders![0].recommend(signals, makeCtx()); + const r = Array.isArray(rec) ? rec[0] : rec; + expect(r.id).toBe("custom-extra-fix"); + expect(r.impact).toBe("low"); + }); +}); diff --git a/src/services/__tests__/policy-engine-types.test.ts b/src/services/__tests__/policy-engine-types.test.ts new file mode 100644 index 0000000..2057ed3 --- /dev/null +++ b/src/services/__tests__/policy-engine-types.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from "vitest"; + +import type { Signal, Recommendation, SignalPatch, RecommendationPatch } from "../policy/types"; +import { + calculateScore, + applySignalPatch, + applyRecommendationPatch, + resolveSupersedes +} from "../policy/types"; + +// ─── Helpers ─── + +function makeSignal(overrides: Partial & { id: string }): Signal { + return { + kind: "file", + status: "detected", + label: overrides.id, + origin: { addedBy: "test-plugin" }, + ...overrides + }; +} + +function makeRec( + overrides: Partial & { id: string; signalId: string } +): Recommendation { + return { + impact: "medium", + message: `Fix ${overrides.id}`, + origin: { addedBy: "test-plugin" }, + ...overrides + }; +} + +// ─── calculateScore ─── + +describe("calculateScore", () => { + it("returns perfect score when no recommendations exist", () => { + const signals = [makeSignal({ id: "s1" })]; + const { score, grade } = calculateScore(signals, []); + expect(score).toBe(1); + expect(grade).toBe("A"); + }); + + it("returns perfect score when signals array is empty", () => { + const { score, grade } = calculateScore([], []); + expect(score).toBe(1); + expect(grade).toBe("A"); + }); + + it("deducts score based on recommendation impact weights", () => { + const signals = [makeSignal({ id: "s1" }), makeSignal({ id: "s2" })]; + // maxWeight = 2 * 5 (critical) = 10 + // one medium rec = weight 3, deduction = 3/10 = 0.3, score = 0.7 + const recs = [makeRec({ id: "r1", signalId: "s1", impact: "medium" })]; + const { score } = calculateScore(signals, recs); + expect(score).toBeCloseTo(0.7, 3); + }); + + it("returns grade F for high deductions", () => { + const signals = [makeSignal({ id: "s1" })]; + // maxWeight = 1 * 5 = 5, critical = 5, deduction = 5/5 = 1 → score = 0 + const recs = [makeRec({ id: "r1", signalId: "s1", impact: "critical" })]; + const { score, grade } = calculateScore(signals, recs); + expect(score).toBe(0); + expect(grade).toBe("F"); + }); + + it("info impact does not affect score", () => { + const signals = [makeSignal({ id: "s1" })]; + const recs = [makeRec({ id: "r1", signalId: "s1", impact: "info" })]; + const { score } = calculateScore(signals, recs); + expect(score).toBe(1); + }); + + it("assigns grade B for score 0.8-0.89", () => { + const signals = [ + makeSignal({ id: "s1" }), + makeSignal({ id: "s2" }), + makeSignal({ id: "s3" }), + makeSignal({ id: "s4" }), + makeSignal({ id: "s5" }) + ]; + // maxWeight = 5 * 5 = 25 + // one high rec = 4, score = 1 - 4/25 = 0.84 → B + const recs = [makeRec({ id: "r1", signalId: "s1", impact: "high" })]; + const { grade } = calculateScore(signals, recs); + expect(grade).toBe("B"); + }); + + it("assigns grade C for score 0.7-0.79", () => { + // 5 signals, maxWeight = 25, two medium recs = 6, score = 1 - 6/25 = 0.76 → C + const signals = [ + makeSignal({ id: "s1" }), + makeSignal({ id: "s2" }), + makeSignal({ id: "s3" }), + makeSignal({ id: "s4" }), + makeSignal({ id: "s5" }) + ]; + const recs = [ + makeRec({ id: "r1", signalId: "s1", impact: "medium" }), + makeRec({ id: "r2", signalId: "s2", impact: "medium" }) + ]; + const { grade } = calculateScore(signals, recs); + expect(grade).toBe("C"); + }); + + it("assigns grade D for score 0.6-0.69", () => { + const signals = [ + makeSignal({ id: "s1" }), + makeSignal({ id: "s2" }), + makeSignal({ id: "s3" }), + makeSignal({ id: "s4" }), + makeSignal({ id: "s5" }) + ]; + // maxWeight = 5 * 5 = 25, two high recs = 8, score = 1 - 8/25 = 0.68 → D + const recs = [ + makeRec({ id: "r1", signalId: "s1", impact: "high" }), + makeRec({ id: "r2", signalId: "s2", impact: "high" }) + ]; + const { grade } = calculateScore(signals, recs); + expect(grade).toBe("D"); + }); + it("clamps score to 0 when deductions exceed maxWeight", () => { + const signals = [makeSignal({ id: "s1" })]; + // maxWeight = 1 * 5 = 5, two critical recs = 10, clamped to 0 + const recs = [ + makeRec({ id: "r1", signalId: "s1", impact: "critical" }), + makeRec({ id: "r2", signalId: "s1", impact: "critical" }) + ]; + const { score, grade } = calculateScore(signals, recs); + expect(score).toBe(0); + expect(grade).toBe("F"); + }); + + it("excludes not-detected signals from score denominator", () => { + // 3 signals: 2 detected, 1 not-detected (skipped) + // maxWeight should be 2 * 5 = 10, NOT 3 * 5 = 15 + const signals = [ + makeSignal({ id: "s1", status: "detected" }), + makeSignal({ id: "s2", status: "detected" }), + makeSignal({ id: "s3", status: "not-detected" }) + ]; + const recs = [makeRec({ id: "r1", signalId: "s1", impact: "medium" })]; + // deductions = 3 (medium), maxWeight = 2 * 5 = 10, score = 1 - 3/10 = 0.7 + const { score } = calculateScore(signals, recs); + expect(score).toBeCloseTo(0.7, 3); + }); + + it("returns perfect score when all signals are not-detected", () => { + const signals = [ + makeSignal({ id: "s1", status: "not-detected" }), + makeSignal({ id: "s2", status: "not-detected" }) + ]; + const { score, grade } = calculateScore(signals, []); + expect(score).toBe(1); + expect(grade).toBe("A"); + }); +}); + +// ─── applySignalPatch ─── + +describe("applySignalPatch", () => { + it("adds new signals", () => { + const signals = [makeSignal({ id: "s1" })]; + const patch: SignalPatch = { + add: [makeSignal({ id: "s2", origin: { addedBy: "hook-plugin" } })] + }; + const result = applySignalPatch(signals, patch, "hook-plugin"); + expect(result).toHaveLength(2); + expect(result[1].id).toBe("s2"); + }); + + it("removes signals by id", () => { + const signals = [makeSignal({ id: "s1" }), makeSignal({ id: "s2" })]; + const patch: SignalPatch = { remove: ["s1"] }; + const result = applySignalPatch(signals, patch, "hook-plugin"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("s2"); + }); + + it("modifies signals and records provenance", () => { + const signals = [makeSignal({ id: "s1", status: "detected" })]; + const patch: SignalPatch = { + modify: [{ id: "s1", changes: { status: "not-detected" } }] + }; + const result = applySignalPatch(signals, patch, "hook-plugin"); + expect(result[0].status).toBe("not-detected"); + expect(result[0].origin.modifiedBy).toEqual(["hook-plugin"]); + }); + + it("accumulates multiple modifiers in provenance", () => { + const signals = [ + makeSignal({ + id: "s1", + origin: { addedBy: "original", modifiedBy: ["first-mod"] } + }) + ]; + const patch: SignalPatch = { + modify: [{ id: "s1", changes: { label: "Updated" } }] + }; + const result = applySignalPatch(signals, patch, "second-mod"); + expect(result[0].origin.modifiedBy).toEqual(["first-mod", "second-mod"]); + }); + + it("does not mutate the original array", () => { + const original = makeSignal({ id: "s1" }); + const signals = [original]; + const patch: SignalPatch = { remove: ["s1"] }; + applySignalPatch(signals, patch, "hook"); + expect(signals).toHaveLength(1); // original unchanged + }); + + it("ignores modify for non-existent signal id", () => { + const signals = [makeSignal({ id: "s1" })]; + const patch: SignalPatch = { + modify: [{ id: "nonexistent", changes: { label: "Nope" } }] + }; + const result = applySignalPatch(signals, patch, "hook"); + expect(result).toHaveLength(1); + expect(result[0].label).toBe("s1"); + }); + + it("applies remove, modify, add in correct order", () => { + const signals = [makeSignal({ id: "s1" }), makeSignal({ id: "s2" })]; + const patch: SignalPatch = { + remove: ["s1"], + modify: [{ id: "s2", changes: { label: "Modified" } }], + add: [makeSignal({ id: "s3", origin: { addedBy: "hook" } })] + }; + const result = applySignalPatch(signals, patch, "hook"); + expect(result.map((s) => s.id)).toEqual(["s2", "s3"]); + expect(result[0].label).toBe("Modified"); + }); + + it("handles empty patch as a no-op", () => { + const signals = [makeSignal({ id: "s1" })]; + const result = applySignalPatch(signals, {}, "hook"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("s1"); + }); + + it("deep-clones evidence and metadata in add", () => { + const evidence = ["file.ts"]; + const metadata = { key: "value" }; + const patch: SignalPatch = { + add: [makeSignal({ id: "s2", evidence, metadata, origin: { addedBy: "hook" } })] + }; + const result = applySignalPatch([], patch, "hook"); + // Mutating original should not affect result + evidence.push("other.ts"); + metadata.key = "mutated"; + expect(result[0].evidence).toEqual(["file.ts"]); + expect(result[0].metadata).toEqual({ key: "value" }); + }); + + it("deep-merges metadata on modify instead of replacing", () => { + const signals = [ + makeSignal({ + id: "s1", + metadata: { pillar: "docs", level: 1, checkStatus: "pass" } + }) + ]; + const patch: SignalPatch = { + modify: [{ id: "s1", changes: { metadata: { pillar: "testing" } } }] + }; + const result = applySignalPatch(signals, patch, "hook"); + // pillar is overridden, but checkStatus and level are preserved + expect(result[0].metadata).toEqual({ pillar: "testing", level: 1, checkStatus: "pass" }); + }); +}); + +// ─── applyRecommendationPatch ─── + +describe("applyRecommendationPatch", () => { + it("adds new recommendations", () => { + const recs = [makeRec({ id: "r1", signalId: "s1" })]; + const patch: RecommendationPatch = { + add: [makeRec({ id: "r2", signalId: "s2", origin: { addedBy: "hook" } })] + }; + const result = applyRecommendationPatch(recs, patch, "hook"); + expect(result).toHaveLength(2); + }); + + it("removes recommendations by id", () => { + const recs = [makeRec({ id: "r1", signalId: "s1" }), makeRec({ id: "r2", signalId: "s2" })]; + const patch: RecommendationPatch = { remove: ["r1"] }; + const result = applyRecommendationPatch(recs, patch, "hook"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("r2"); + }); + + it("modifies recommendations and records provenance", () => { + const recs = [makeRec({ id: "r1", signalId: "s1", impact: "low" })]; + const patch: RecommendationPatch = { + modify: [{ id: "r1", changes: { impact: "critical" } }] + }; + const result = applyRecommendationPatch(recs, patch, "escalator"); + expect(result[0].impact).toBe("critical"); + expect(result[0].origin.modifiedBy).toEqual(["escalator"]); + }); + + it("does not mutate the original array", () => { + const original = makeRec({ id: "r1", signalId: "s1" }); + const recs = [original]; + const patch: RecommendationPatch = { remove: ["r1"] }; + applyRecommendationPatch(recs, patch, "hook"); + expect(recs).toHaveLength(1); + }); + + it("ignores modify for non-existent recommendation id", () => { + const recs = [makeRec({ id: "r1", signalId: "s1" })]; + const patch: RecommendationPatch = { + modify: [{ id: "nonexistent", changes: { message: "Nope" } }] + }; + const result = applyRecommendationPatch(recs, patch, "hook"); + expect(result).toHaveLength(1); + expect(result[0].message).toBe("Fix r1"); + }); + + it("accumulates multiple modifiers in provenance", () => { + const recs = [ + makeRec({ + id: "r1", + signalId: "s1", + origin: { addedBy: "original", modifiedBy: ["first-mod"] } + }) + ]; + const patch: RecommendationPatch = { + modify: [{ id: "r1", changes: { message: "Updated" } }] + }; + const result = applyRecommendationPatch(recs, patch, "second-mod"); + expect(result[0].origin.modifiedBy).toEqual(["first-mod", "second-mod"]); + }); + + it("applies remove, modify, add in correct order", () => { + const recs = [makeRec({ id: "r1", signalId: "s1" }), makeRec({ id: "r2", signalId: "s2" })]; + const patch: RecommendationPatch = { + remove: ["r1"], + modify: [{ id: "r2", changes: { message: "Modified" } }], + add: [makeRec({ id: "r3", signalId: "s3", origin: { addedBy: "hook" } })] + }; + const result = applyRecommendationPatch(recs, patch, "hook"); + expect(result.map((r) => r.id)).toEqual(["r2", "r3"]); + expect(result[0].message).toBe("Modified"); + }); +}); + +// ─── resolveSupersedes ─── + +describe("resolveSupersedes", () => { + it("removes recommendations that are superseded", () => { + const recs = [ + makeRec({ id: "r1", signalId: "s1" }), + makeRec({ id: "r2", signalId: "s1", supersedes: ["r1"] }) + ]; + const result = resolveSupersedes(recs); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("r2"); + }); + + it("keeps all recommendations when none are superseded", () => { + const recs = [makeRec({ id: "r1", signalId: "s1" }), makeRec({ id: "r2", signalId: "s2" })]; + const result = resolveSupersedes(recs); + expect(result).toHaveLength(2); + }); + + it("handles transitive supersedes", () => { + const recs = [ + makeRec({ id: "r1", signalId: "s1" }), + makeRec({ id: "r2", signalId: "s1", supersedes: ["r1"] }), + makeRec({ id: "r3", signalId: "s1", supersedes: ["r2"] }) + ]; + const result = resolveSupersedes(recs); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("r3"); + }); + + it("handles multiple supersedes in one recommendation", () => { + const recs = [ + makeRec({ id: "r1", signalId: "s1" }), + makeRec({ id: "r2", signalId: "s2" }), + makeRec({ id: "r3", signalId: "s1", supersedes: ["r1", "r2"] }) + ]; + const result = resolveSupersedes(recs); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("r3"); + }); + + it("drops both recommendations in circular supersedes", () => { + const recs = [ + makeRec({ id: "r1", signalId: "s1", supersedes: ["r2"] }), + makeRec({ id: "r2", signalId: "s1", supersedes: ["r1"] }) + ]; + const result = resolveSupersedes(recs); + expect(result).toHaveLength(0); + }); + + it("records provenance on the superseding recommendation", () => { + const recs = [ + makeRec({ id: "r1", signalId: "s1" }), + makeRec({ id: "r2", signalId: "s1", supersedes: ["r1"] }) + ]; + const result = resolveSupersedes(recs); + expect(result[0].origin.modifiedBy).toEqual(["superseded:r1"]); + }); + + it("ignores supersedes references to non-existent IDs", () => { + const recs = [makeRec({ id: "r1", signalId: "s1", supersedes: ["nonexistent"] })]; + const result = resolveSupersedes(recs); + expect(result).toHaveLength(1); + expect(result[0].origin.modifiedBy).toBeUndefined(); + }); +}); diff --git a/src/services/__tests__/policy-engine.test.ts b/src/services/__tests__/policy-engine.test.ts new file mode 100644 index 0000000..16f62f2 --- /dev/null +++ b/src/services/__tests__/policy-engine.test.ts @@ -0,0 +1,607 @@ +import { describe, expect, it, vi } from "vitest"; + +import { executePlugins } from "../policy/engine"; +import type { + PolicyPlugin, + PolicyContext, + Signal, + Recommendation, + Detector, + Recommender +} from "../policy/types"; + +// ─── Helpers ─── + +function makeCtx(overrides?: Partial): PolicyContext { + return { + repoPath: "/tmp/test-repo", + rootFiles: ["README.md", "package.json"], + cache: new Map(), + ...overrides + }; +} + +function makeDetector(id: string, result: Signal | Signal[]): Detector { + return { + id, + kind: "file", + detect: vi.fn().mockResolvedValue(result) + }; +} + +function makeRecommender(id: string, result: Recommendation | Recommendation[]): Recommender { + return { + id, + recommend: vi.fn().mockResolvedValue(result) + }; +} + +function makePlugin( + overrides: Partial & { meta: PolicyPlugin["meta"] } +): PolicyPlugin { + return { ...overrides }; +} + +const META = { + name: "test-plugin", + sourceType: "builtin" as const, + trust: "trusted-code" as const +}; + +// ─── executePlugins ─── + +describe("executePlugins", () => { + it("runs detectors and recommenders through the full pipeline", async () => { + const signal: Signal = { + id: "readme-missing", + kind: "file", + status: "detected", + label: "README missing", + origin: { addedBy: "test-plugin" } + }; + const rec: Recommendation = { + id: "add-readme", + signalId: "readme-missing", + impact: "high", + message: "Add a README.md", + origin: { addedBy: "test-plugin" } + }; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("readme-check", signal)], + recommenders: [makeRecommender("readme-rec", rec)] + }); + + const report = await executePlugins([plugin], makeCtx()); + + expect(report.signals).toHaveLength(1); + expect(report.signals[0].id).toBe("readme-missing"); + expect(report.recommendations).toHaveLength(1); + expect(report.recommendations[0].id).toBe("add-readme"); + expect(report.pluginChain).toEqual(["test-plugin"]); + expect(report.score).toBeLessThan(1); + }); + + it("executes multiple plugins in source order", async () => { + const order: string[] = []; + + const plugin1 = makePlugin({ + meta: { ...META, name: "plugin-1" }, + detectors: [ + { + id: "d1", + kind: "file", + detect: async () => { + order.push("detect-1"); + return { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "plugin-1" } + }; + } + } + ] + }); + + const plugin2 = makePlugin({ + meta: { ...META, name: "plugin-2" }, + detectors: [ + { + id: "d2", + kind: "file", + detect: async () => { + order.push("detect-2"); + return { + id: "s2", + kind: "file", + status: "detected", + label: "S2", + origin: { addedBy: "plugin-2" } + }; + } + } + ] + }); + + await executePlugins([plugin1, plugin2], makeCtx()); + expect(order).toEqual(["detect-1", "detect-2"]); + }); + + it("applies afterDetect hook patches", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "Original", + origin: { addedBy: "base" } + }; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + afterDetect: async () => ({ + modify: [{ id: "s1", changes: { label: "Patched" } }] + }), + recommenders: [ + { + id: "r1", + recommend: async (signals) => { + // Verify recommender sees the patched signal + expect(signals[0].label).toBe("Patched"); + return []; + } + } + ] + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.signals[0].label).toBe("Patched"); + expect(report.signals[0].origin.modifiedBy).toEqual(["test-plugin"]); + }); + + it("applies afterRecommend hook patches", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "base" } + }; + const rec: Recommendation = { + id: "r1", + signalId: "s1", + impact: "low", + message: "Original", + origin: { addedBy: "base" } + }; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + recommenders: [makeRecommender("rec1", rec)], + afterRecommend: async () => ({ + modify: [{ id: "r1", changes: { impact: "critical" } }] + }) + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.recommendations[0].impact).toBe("critical"); + }); + + it("skips disabled rule IDs for detectors and recommenders", async () => { + const plugin = makePlugin({ + meta: META, + detectors: [ + makeDetector("enabled-d", { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "test" } + }), + makeDetector("disabled-d", { + id: "s2", + kind: "file", + status: "detected", + label: "S2", + origin: { addedBy: "test" } + }) + ], + recommenders: [ + makeRecommender("enabled-r", { + id: "r1", + signalId: "s1", + impact: "medium", + message: "R1", + origin: { addedBy: "test" } + }), + makeRecommender("disabled-r", { + id: "r2", + signalId: "s2", + impact: "medium", + message: "R2", + origin: { addedBy: "test" } + }) + ] + }); + + const report = await executePlugins([plugin], makeCtx(), { + disabledRuleIds: new Set(["disabled-d", "disabled-r"]) + }); + + expect(report.signals).toHaveLength(1); + expect(report.signals[0].id).toBe("s1"); + expect(report.recommendations).toHaveLength(1); + expect(report.recommendations[0].id).toBe("r1"); + }); + + it("resolves supersedes in final output", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "test" } + }; + const recs: Recommendation[] = [ + { id: "r1", signalId: "s1", impact: "low", message: "Base", origin: { addedBy: "base" } }, + { + id: "r2", + signalId: "s1", + impact: "high", + message: "Override", + origin: { addedBy: "override" }, + supersedes: ["r1"] + } + ]; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + recommenders: [makeRecommender("rec1", recs)] + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.recommendations).toHaveLength(1); + expect(report.recommendations[0].id).toBe("r2"); + }); + + it("records warnings for caught errors and continues", async () => { + const plugin = makePlugin({ + meta: META, + detectors: [ + { + id: "failing", + kind: "file", + detect: async () => { + throw new Error("disk read failed"); + } + } + ], + onError: () => true // continue on error + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.policyWarnings).toHaveLength(1); + expect(report.policyWarnings[0].pluginName).toBe("test-plugin"); + expect(report.policyWarnings[0].stage).toBe("detect"); + expect(report.policyWarnings[0].message).toBe("disk read failed"); + }); + + it("returns empty report for no plugins", async () => { + const report = await executePlugins([], makeCtx()); + expect(report.signals).toEqual([]); + expect(report.recommendations).toEqual([]); + expect(report.score).toBe(1); + expect(report.grade).toBe("A"); + expect(report.pluginChain).toEqual([]); + }); + + it("shares the same context cache across plugins", async () => { + const plugin1 = makePlugin({ + meta: { ...META, name: "writer" }, + detectors: [ + { + id: "d1", + kind: "custom", + detect: async (ctx) => { + ctx.cache.set("shared-key", 42); + return { + id: "s1", + kind: "custom", + status: "detected", + label: "S1", + origin: { addedBy: "writer" } + }; + } + } + ] + }); + + const plugin2 = makePlugin({ + meta: { ...META, name: "reader" }, + detectors: [ + { + id: "d2", + kind: "custom", + detect: async (ctx) => { + const val = ctx.cache.get("shared-key"); + return { + id: "s2", + kind: "custom", + status: val === 42 ? "detected" : "error", + label: "S2", + origin: { addedBy: "reader" } + }; + } + } + ] + }); + + const report = await executePlugins([plugin1, plugin2], makeCtx()); + expect(report.signals[1].status).toBe("detected"); + }); + + it("produces deterministic output for identical inputs", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "test" } + }; + const rec: Recommendation = { + id: "r1", + signalId: "s1", + impact: "medium", + message: "Fix it", + origin: { addedBy: "test" } + }; + + const makeRun = () => { + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + recommenders: [makeRecommender("rec1", rec)] + }); + return executePlugins([plugin], makeCtx()); + }; + + const [report1, report2] = await Promise.all([makeRun(), makeRun()]); + expect(report1.signals).toEqual(report2.signals); + expect(report1.recommendations).toEqual(report2.recommendations); + expect(report1.score).toBe(report2.score); + expect(report1.grade).toBe(report2.grade); + }); + + it("applies beforeRecommend hook patches visible to recommenders", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "Original", + origin: { addedBy: "base" } + }; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + beforeRecommend: async () => ({ + modify: [{ id: "s1", changes: { label: "Pre-recommend patched" } }] + }), + recommenders: [ + { + id: "r1", + recommend: async (signals) => { + expect(signals[0].label).toBe("Pre-recommend patched"); + return []; + } + } + ] + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.signals[0].label).toBe("Pre-recommend patched"); + expect(report.signals[0].origin.modifiedBy).toEqual(["test-plugin"]); + }); + + it("aborts plugin stage when onError returns false", async () => { + const secondDetect = vi.fn().mockResolvedValue({ + id: "s2", + kind: "file", + status: "detected", + label: "S2", + origin: { addedBy: "test" } + }); + + const plugin = makePlugin({ + meta: META, + detectors: [ + { + id: "failing", + kind: "file", + detect: async () => { + throw new Error("abort trigger"); + } + }, + { + id: "skipped", + kind: "file", + detect: secondDetect + } + ], + onError: () => false // abort + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(secondDetect).not.toHaveBeenCalled(); + expect(report.policyWarnings).toHaveLength(1); + expect(report.policyWarnings[0].message).toBe("abort trigger"); + }); + + it("handles detectors returning arrays", async () => { + const signals: Signal[] = [ + { id: "s1", kind: "file", status: "detected", label: "S1", origin: { addedBy: "test" } }, + { id: "s2", kind: "git", status: "detected", label: "S2", origin: { addedBy: "test" } } + ]; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("multi", signals)] + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.signals).toHaveLength(2); + expect(report.signals.map((s) => s.id)).toEqual(["s1", "s2"]); + }); + + it("handles recommenders returning arrays", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "test" } + }; + const recs: Recommendation[] = [ + { id: "r1", signalId: "s1", impact: "medium", message: "R1", origin: { addedBy: "test" } }, + { id: "r2", signalId: "s1", impact: "low", message: "R2", origin: { addedBy: "test" } } + ]; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + recommenders: [makeRecommender("multi-rec", recs)] + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.recommendations).toHaveLength(2); + }); + + it("records warnings for recommender errors and continues", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "test" } + }; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + recommenders: [ + { + id: "failing-rec", + recommend: async () => { + throw new Error("recommend failed"); + } + } + ], + onError: () => true + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.policyWarnings).toHaveLength(1); + expect(report.policyWarnings[0].stage).toBe("recommend"); + expect(report.policyWarnings[0].message).toBe("recommend failed"); + }); + + it("continues and records warning when no onError handler is provided", async () => { + const plugin = makePlugin({ + meta: META, + detectors: [ + { + id: "failing", + kind: "file", + detect: async () => { + throw new Error("no handler"); + } + } + ] + // no onError + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.policyWarnings).toHaveLength(1); + expect(report.policyWarnings[0].message).toBe("no handler"); + }); + + it("handles non-Error throwables in detectors", async () => { + const plugin = makePlugin({ + meta: META, + detectors: [ + { + id: "string-throw", + kind: "file", + detect: async () => { + throw "string error"; + } + } + ] + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.policyWarnings).toHaveLength(1); + expect(report.policyWarnings[0].message).toBe("string error"); + }); + + it("records warnings for afterDetect hook errors", async () => { + const signal: Signal = { + id: "s1", + kind: "file", + status: "detected", + label: "S1", + origin: { addedBy: "test" } + }; + + const plugin = makePlugin({ + meta: META, + detectors: [makeDetector("d1", signal)], + afterDetect: async () => { + throw new Error("afterDetect failed"); + } + }); + + const report = await executePlugins([plugin], makeCtx()); + expect(report.policyWarnings).toHaveLength(1); + expect(report.policyWarnings[0].stage).toBe("afterDetect"); + }); + + it("continues other plugins' detectors after one plugin aborts", async () => { + const plugin1 = makePlugin({ + meta: { ...META, name: "aborter" }, + detectors: [ + { + id: "d1", + kind: "file", + detect: async () => { + throw new Error("abort"); + } + } + ], + onError: () => false + }); + + const plugin2 = makePlugin({ + meta: { ...META, name: "survivor" }, + detectors: [ + makeDetector("d2", { + id: "s2", + kind: "file", + status: "detected", + label: "S2", + origin: { addedBy: "survivor" } + }) + ] + }); + + const report = await executePlugins([plugin1, plugin2], makeCtx()); + expect(report.signals).toHaveLength(1); + expect(report.signals[0].id).toBe("s2"); + expect(report.policyWarnings).toHaveLength(1); + }); +}); diff --git a/src/services/__tests__/policy-loader.test.ts b/src/services/__tests__/policy-loader.test.ts new file mode 100644 index 0000000..269dad4 --- /dev/null +++ b/src/services/__tests__/policy-loader.test.ts @@ -0,0 +1,128 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { executePlugins } from "../policy/engine"; +import { buildBuiltinPlugin, loadPluginChain } from "../policy/loader"; +import type { PolicyContext } from "../policy/types"; +import { buildExtras } from "../readiness"; + +function makeCtx(): PolicyContext { + return { + repoPath: "/tmp/test", + rootFiles: [], + cache: new Map() + }; +} + +describe("buildBuiltinPlugin", () => { + it("returns a plugin with sourceType 'builtin' and trust 'trusted-code'", () => { + const { plugin } = buildBuiltinPlugin(); + expect(plugin.meta.name).toBe("builtin"); + expect(plugin.meta.sourceType).toBe("builtin"); + expect(plugin.meta.trust).toBe("trusted-code"); + }); + + it("has detectors for all repo-scoped built-in criteria and extras", () => { + const { plugin, baseCriteria } = buildBuiltinPlugin(); + const extras = buildExtras(); + // Only repo-scoped criteria are included, plus all extras + const repoCriteria = baseCriteria.filter((c) => c.scope === "repo"); + expect(plugin.detectors).toBeDefined(); + expect(plugin.detectors!.length).toBe(repoCriteria.length + extras.length); + }); + + it("has recommenders for all repo-scoped built-in criteria and extras", () => { + const { plugin, baseCriteria } = buildBuiltinPlugin(); + const extras = buildExtras(); + const repoCriteria = baseCriteria.filter((c) => c.scope === "repo"); + expect(plugin.recommenders).toBeDefined(); + expect(plugin.recommenders!.length).toBe(repoCriteria.length + extras.length); + }); + + it("produces a plugin that can execute through the engine", async () => { + const { plugin } = buildBuiltinPlugin(); + const report = await executePlugins([plugin], makeCtx()); + expect(report.signals.length).toBeGreaterThan(0); + expect(report.pluginChain).toEqual(["builtin"]); + expect(report.grade).toBeDefined(); + }); +}); + +describe("loadPluginChain", () => { + it("always includes builtin as the first plugin", async () => { + const chain = await loadPluginChain([]); + expect(chain.plugins.length).toBe(1); + expect(chain.plugins[0].meta.name).toBe("builtin"); + expect(chain.passRateThreshold).toBe(0.8); + }); + + it("returns empty disabledRuleIds when no policies loaded", async () => { + const chain = await loadPluginChain([]); + expect(chain.options.disabledRuleIds).toBeUndefined(); + }); +}); + +describe("loadPluginChain with JSON policy file", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-loader-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("loads a JSON policy and returns 2-plugin chain with policy following builtin", async () => { + const policyPath = path.join(tmpDir, "test-policy.json"); + await fs.writeFile( + policyPath, + JSON.stringify({ name: "test-policy", criteria: { disable: [] } }) + ); + + const chain = await loadPluginChain([policyPath]); + expect(chain.plugins).toHaveLength(2); + expect(chain.plugins[0].meta.name).toBe("builtin"); + expect(chain.plugins[1].meta.name).toBe("test-policy"); + expect(chain.plugins[1].meta.trust).toBe("safe-declarative"); + }); + + it("merges disabledRuleIds from policy criteria.disable", async () => { + const policyPath = path.join(tmpDir, "disable-policy.json"); + await fs.writeFile( + policyPath, + JSON.stringify({ name: "disable-policy", criteria: { disable: ["lint-config", "readme"] } }) + ); + + const chain = await loadPluginChain([policyPath]); + expect(chain.options.disabledRuleIds).toBeDefined(); + expect(chain.options.disabledRuleIds).toContain("lint-config"); + expect(chain.options.disabledRuleIds).toContain("readme"); + }); + + it("picks up passRateThreshold from policy thresholds field", async () => { + const policyPath = path.join(tmpDir, "threshold-policy.json"); + await fs.writeFile( + policyPath, + JSON.stringify({ name: "strict", thresholds: { passRate: 0.95 } }) + ); + + const chain = await loadPluginChain([policyPath]); + expect(chain.passRateThreshold).toBe(0.95); + }); + + it("accepts a safe-declarative policy when jsonOnly is true", async () => { + const policyPath = path.join(tmpDir, "bad-policy.json"); + // compilePolicyConfig with jsonOnly:true rejects policies that declare import sources + // Write a structurally-invalid policy so loadPolicy raises at parse time + await fs.writeFile(policyPath, JSON.stringify({ name: "bad", criteria: { disable: [] } })); + + // jsonOnly flag is set when policySources comes from config (not CLI), but the + // file-based path always resolves to safe-declarative trust — load should succeed + const chain = await loadPluginChain([policyPath], { jsonOnly: true }); + expect(chain.plugins[1].meta.trust).toBe("safe-declarative"); + }); +}); diff --git a/src/services/__tests__/policy-shadow.test.ts b/src/services/__tests__/policy-shadow.test.ts new file mode 100644 index 0000000..02bf8ca --- /dev/null +++ b/src/services/__tests__/policy-shadow.test.ts @@ -0,0 +1,402 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { compareShadow, writeShadowLog } from "../policy/shadow"; +import type { EngineReport } from "../policy/types"; +import type { ReadinessReport, ReadinessCriterionResult } from "../readiness"; + +function makeLegacyReport(criteria: ReadinessCriterionResult[] = []): ReadinessReport { + return { + repoPath: "/tmp/test", + generatedAt: "2025-01-01T00:00:00.000Z", + isMonorepo: false, + apps: [], + pillars: [], + levels: [], + achievedLevel: 0, + criteria, + extras: [] + }; +} + +function makeEngineReport(overrides: Partial = {}): EngineReport { + return { + signals: [], + recommendations: [], + policyWarnings: [], + score: 1, + grade: "A", + pluginChain: ["builtin"], + ...overrides + }; +} + +function makeCriterion(id: string, status: "pass" | "fail" | "skip"): ReadinessCriterionResult { + return { + id, + title: `Criterion ${id}`, + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status + }; +} + +describe("compareShadow", () => { + it("returns legacy report by default", () => { + const legacy = makeLegacyReport(); + const engine = makeEngineReport(); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + expect(result.usedNewEngine).toBe(false); + expect(result.report).toBe(legacy); + }); + + it("returns new engine report when useNewEngine is true", () => { + const legacy = makeLegacyReport(); + const engine = makeEngineReport(); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test", useNewEngine: true }); + expect(result.usedNewEngine).toBe(true); + expect(result.report).not.toBe(legacy); + expect(result.report.repoPath).toBe("/tmp/test"); + }); + + it("reports no discrepancies when both engines agree", () => { + const legacy = makeLegacyReport([makeCriterion("readme", "pass")]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + expect(result.discrepancies).toHaveLength(0); + }); + + it("detects status discrepancy", () => { + const legacy = makeLegacyReport([makeCriterion("readme", "pass")]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "fail" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + expect(result.discrepancies).toHaveLength(1); + expect(result.discrepancies[0].criterionId).toBe("readme"); + expect(result.discrepancies[0].field).toBe("status"); + expect(result.discrepancies[0].legacyValue).toBe("pass"); + expect(result.discrepancies[0].newValue).toBe("fail"); + }); + + it("detects missing criterion in new engine", () => { + const legacy = makeLegacyReport([makeCriterion("custom-thing", "pass")]); + const engine = makeEngineReport(); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + expect(result.discrepancies).toHaveLength(1); + expect(result.discrepancies[0].field).toBe("presence"); + expect(result.discrepancies[0].newValue).toBe("missing"); + }); + + it("detects extra criterion in new engine", () => { + const legacy = makeLegacyReport(); + const engine = makeEngineReport({ + signals: [ + { + id: "new-criterion", + kind: "file", + status: "detected", + label: "New", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "testing", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + expect(result.discrepancies).toHaveLength(1); + expect(result.discrepancies[0].field).toBe("presence"); + expect(result.discrepancies[0].legacyValue).toBe("missing"); + }); + + it("detects pillar discrepancy", () => { + const legacy = makeLegacyReport([ + { + ...makeCriterion("readme", "pass"), + pillar: "documentation" + } + ]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + const pillarDisc = result.discrepancies.find((d) => d.field === "pillar"); + expect(pillarDisc).toBeDefined(); + expect(pillarDisc!.legacyValue).toBe("documentation"); + expect(pillarDisc!.newValue).toBe("ai-tooling"); + }); + + it("detects level discrepancy", () => { + const legacy = makeLegacyReport([ + { + ...makeCriterion("readme", "pass"), + level: 1 + } + ]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "documentation", + level: 3, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + const levelDisc = result.discrepancies.find((d) => d.field === "level"); + expect(levelDisc).toBeDefined(); + expect(levelDisc!.legacyValue).toBe(1); + expect(levelDisc!.newValue).toBe(3); + }); + + it("detects impact discrepancy", () => { + const legacy = makeLegacyReport([{ ...makeCriterion("readme", "pass"), impact: "high" }]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "medium", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + const disc = result.discrepancies.find((d) => d.field === "impact"); + expect(disc).toBeDefined(); + expect(disc!.legacyValue).toBe("high"); + expect(disc!.newValue).toBe("medium"); + }); + + it("detects effort discrepancy", () => { + const legacy = makeLegacyReport([{ ...makeCriterion("readme", "pass"), effort: "low" }]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "high", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + const disc = result.discrepancies.find((d) => d.field === "effort"); + expect(disc).toBeDefined(); + expect(disc!.legacyValue).toBe("low"); + expect(disc!.newValue).toBe("high"); + }); + + it("filters area-scoped legacy criteria before comparison to avoid false-positive presence discrepancies", () => { + const areaCriterion: ReadinessCriterionResult = { + ...makeCriterion("area-readme", "skip"), + scope: "area" + }; + // Legacy report has an area-scoped criterion; engine has none (engine only runs repo-scoped) + const legacy = makeLegacyReport([makeCriterion("readme", "pass"), areaCriterion]); + const engine = makeEngineReport({ + signals: [ + { + id: "readme", + kind: "file", + status: "detected", + label: "README present", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + // area-readme must not produce a "presence: missing" discrepancy + const presenceDiscs = result.discrepancies.filter((d) => d.field === "presence"); + expect(presenceDiscs).toHaveLength(0); + expect(result.discrepancies).toHaveLength(0); + }); + + it("asserts both values for extra criterion in new engine", () => { + const legacy = makeLegacyReport(); + const engine = makeEngineReport({ + signals: [ + { + id: "new-criterion", + kind: "file", + status: "detected", + label: "New", + origin: { addedBy: "builtin" }, + metadata: { + pillar: "testing", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + checkStatus: "pass" + } + } + ] + }); + const result = compareShadow(legacy, engine, { repoPath: "/tmp/test" }); + expect(result.discrepancies).toHaveLength(1); + expect(result.discrepancies[0].legacyValue).toBe("missing"); + expect(result.discrepancies[0].newValue).toBe("exists"); + }); +}); + +describe("writeShadowLog", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-shadow-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("is a no-op when discrepancies array is empty", async () => { + await writeShadowLog(tmpDir, []); + const logPath = path.join(tmpDir, ".primer-cache", "shadow-mode.log"); + let exists = false; + try { + await fs.access(logPath); + exists = true; + } catch { + // expected — file should not be created + } + expect(exists).toBe(false); + }); + + it("creates the log file and appends discrepancy details", async () => { + const discrepancies = [ + { criterionId: "readme", field: "status", legacyValue: "pass", newValue: "fail" } + ]; + await writeShadowLog(tmpDir, discrepancies); + const logPath = path.join(tmpDir, ".primer-cache", "shadow-mode.log"); + const content = await fs.readFile(logPath, "utf-8"); + expect(content).toContain('["readme"] status'); + expect(content).toContain('"pass"'); + expect(content).toContain('"fail"'); + }); + + it("appends on successive calls (does not overwrite)", async () => { + const d = [{ criterionId: "readme", field: "status", legacyValue: "pass", newValue: "fail" }]; + await writeShadowLog(tmpDir, d); + await writeShadowLog(tmpDir, d); + const logPath = path.join(tmpDir, ".primer-cache", "shadow-mode.log"); + const content = await fs.readFile(logPath, "utf-8"); + const matches = content.match(/\["readme"\] status/g) ?? []; + expect(matches).toHaveLength(2); + }); + + it("creates the .primer-cache directory if it does not exist", async () => { + // No .primer-cache dir created — writeShadowLog must mkdir it + const discrepancies = [ + { + criterionId: "codeowners", + field: "pillar", + legacyValue: "security-governance", + newValue: "documentation" + } + ]; + await writeShadowLog(tmpDir, discrepancies); + const logPath = path.join(tmpDir, ".primer-cache", "shadow-mode.log"); + const stat = await fs.stat(logPath); + expect(stat.isFile()).toBe(true); + }); +}); diff --git a/src/services/__tests__/policy.test.ts b/src/services/__tests__/policy.test.ts new file mode 100644 index 0000000..aabc05f --- /dev/null +++ b/src/services/__tests__/policy.test.ts @@ -0,0 +1,517 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ExtraDefinition, PolicyConfig } from "../policy"; +import { loadPolicy, resolveChain, parsePolicySources } from "../policy"; +import type { ReadinessCriterion } from "../readiness"; + +// ─── Helpers ─── + +function makeCriterion( + overrides: Partial & { id: string } +): ReadinessCriterion { + return { + title: overrides.id, + pillar: "build-system", + level: 1, + scope: "repo", + impact: "medium", + effort: "low", + check: async () => ({ status: "pass" }), + ...overrides + }; +} + +function makeExtra(overrides: Partial & { id: string }): ExtraDefinition { + return { + title: overrides.id, + check: async () => ({ status: "pass" }), + ...overrides + }; +} + +// ─── resolveChain ─── + +describe("resolveChain", () => { + it("returns base criteria and extras unchanged when policies is empty", () => { + const criteria = [makeCriterion({ id: "a" }), makeCriterion({ id: "b" })]; + const extras = [makeExtra({ id: "x" })]; + const result = resolveChain(criteria, extras, []); + + expect(result.chain).toEqual([]); + expect(result.criteria).toHaveLength(2); + expect(result.extras).toHaveLength(1); + expect(result.thresholds.passRate).toBe(0.8); + }); + + it("does not mutate the original baseCriteria objects", () => { + const original = makeCriterion({ id: "a", impact: "low" }); + const criteria = [original]; + const policy: PolicyConfig = { + name: "test", + criteria: { override: { a: { impact: "high" } } } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria[0].impact).toBe("high"); + expect(original.impact).toBe("low"); // original untouched + }); + + it("disables criteria by id", () => { + const criteria = [ + makeCriterion({ id: "a" }), + makeCriterion({ id: "b" }), + makeCriterion({ id: "c" }) + ]; + const policy: PolicyConfig = { + name: "test", + criteria: { disable: ["a", "c"] } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria.map((c) => c.id)).toEqual(["b"]); + }); + + it("overrides criterion metadata by id", () => { + const criteria = [makeCriterion({ id: "a", level: 1, impact: "low" })]; + const policy: PolicyConfig = { + name: "test", + criteria: { override: { a: { level: 3, impact: "high" } } } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria[0].level).toBe(3); + expect(result.criteria[0].impact).toBe("high"); + expect(result.criteria[0].id).toBe("a"); // id preserved + }); + + it("ignores override for non-existent criterion id", () => { + const criteria = [makeCriterion({ id: "a" })]; + const policy: PolicyConfig = { + name: "test", + criteria: { override: { nonexistent: { level: 5 } } } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria).toHaveLength(1); + expect(result.criteria[0].id).toBe("a"); + }); + + it("adds new criteria", () => { + const criteria = [makeCriterion({ id: "a" })]; + const newCriterion = makeCriterion({ id: "b", title: "New check" }); + const policy: PolicyConfig = { + name: "test", + criteria: { add: [newCriterion] } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria).toHaveLength(2); + expect(result.criteria[1].id).toBe("b"); + expect(result.criteria[1].title).toBe("New check"); + }); + + it("replaces existing criterion when adding with same id", () => { + const criteria = [makeCriterion({ id: "a", title: "Original" })]; + const replacement = makeCriterion({ id: "a", title: "Replaced" }); + const policy: PolicyConfig = { + name: "test", + criteria: { add: [replacement] } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria).toHaveLength(1); + expect(result.criteria[0].title).toBe("Replaced"); + }); + + it("disables extras by id", () => { + const extras = [makeExtra({ id: "x" }), makeExtra({ id: "y" })]; + const policy: PolicyConfig = { + name: "test", + extras: { disable: ["x"] } + }; + + const result = resolveChain([], extras, [policy]); + expect(result.extras.map((e) => e.id)).toEqual(["y"]); + }); + + it("adds new extras", () => { + const extras = [makeExtra({ id: "x" })]; + const newExtra = makeExtra({ id: "y", title: "New extra" }); + const policy: PolicyConfig = { + name: "test", + extras: { add: [newExtra] } + }; + + const result = resolveChain([], extras, [policy]); + expect(result.extras).toHaveLength(2); + expect(result.extras[1].title).toBe("New extra"); + }); + + it("replaces existing extra when adding with same id", () => { + const extras = [makeExtra({ id: "x", title: "Original" })]; + const replacement = makeExtra({ id: "x", title: "Replaced" }); + const policy: PolicyConfig = { + name: "test", + extras: { add: [replacement] } + }; + + const result = resolveChain([], extras, [policy]); + expect(result.extras).toHaveLength(1); + expect(result.extras[0].title).toBe("Replaced"); + }); + + it("overrides passRate threshold", () => { + const policy: PolicyConfig = { + name: "strict", + thresholds: { passRate: 0.95 } + }; + + const result = resolveChain([], [], [policy]); + expect(result.thresholds.passRate).toBe(0.95); + }); + + it("stacks multiple policies in order (last wins)", () => { + const criteria = [ + makeCriterion({ id: "a" }), + makeCriterion({ id: "b" }), + makeCriterion({ id: "c" }) + ]; + const policy1: PolicyConfig = { + name: "base", + criteria: { disable: ["c"] }, + thresholds: { passRate: 0.9 } + }; + const policy2: PolicyConfig = { + name: "override", + criteria: { override: { a: { impact: "high" } } }, + thresholds: { passRate: 0.7 } + }; + + const result = resolveChain(criteria, [], [policy1, policy2]); + expect(result.chain).toEqual(["base", "override"]); + expect(result.criteria.map((c) => c.id)).toEqual(["a", "b"]); // c disabled by policy1 + expect(result.criteria[0].impact).toBe("high"); // overridden by policy2 + expect(result.thresholds.passRate).toBe(0.7); // last wins + }); + + it("applies disable, override, add in correct order within a policy", () => { + const criteria = [makeCriterion({ id: "a", level: 1 }), makeCriterion({ id: "b" })]; + const policy: PolicyConfig = { + name: "combo", + criteria: { + disable: ["b"], + override: { a: { level: 3 } }, + add: [makeCriterion({ id: "c", title: "Added" })] + } + }; + + const result = resolveChain(criteria, [], [policy]); + expect(result.criteria.map((c) => c.id)).toEqual(["a", "c"]); + expect(result.criteria[0].level).toBe(3); + expect(result.criteria[1].title).toBe("Added"); + }); +}); + +// ─── loadPolicy (JSON files) ─── + +describe("loadPolicy", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-policy-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writePolicy(filename: string, content: unknown): Promise { + const filePath = path.join(tmpDir, filename); + await fs.writeFile(filePath, JSON.stringify(content), "utf8"); + return filePath; + } + + it("loads a valid JSON policy", async () => { + const filePath = await writePolicy("policy.json", { + name: "my-policy", + thresholds: { passRate: 0.9 } + }); + + const config = await loadPolicy(filePath); + expect(config.name).toBe("my-policy"); + expect(config.thresholds?.passRate).toBe(0.9); + }); + + it("loads a minimal JSON policy (name only)", async () => { + const filePath = await writePolicy("minimal.json", { name: "minimal" }); + const config = await loadPolicy(filePath); + expect(config.name).toBe("minimal"); + }); + + it("loads a policy with criteria.disable", async () => { + const filePath = await writePolicy("disable.json", { + name: "strict", + criteria: { disable: ["lint-config", "readme"] } + }); + + const config = await loadPolicy(filePath); + expect(config.criteria?.disable).toEqual(["lint-config", "readme"]); + }); + + it("throws for missing JSON file", async () => { + const badPath = path.join(tmpDir, "nonexistent.json"); + await expect(loadPolicy(badPath)).rejects.toThrow("not found at:"); + }); + + it("throws for missing name field", async () => { + const filePath = await writePolicy("no-name.json", { thresholds: { passRate: 0.5 } }); + await expect(loadPolicy(filePath)).rejects.toThrow('missing required field "name"'); + }); + + it("throws for non-object input", async () => { + const filePath = path.join(tmpDir, "bad.json"); + await fs.writeFile(filePath, '"just a string"', "utf8"); + await expect(loadPolicy(filePath)).rejects.toThrow("expected an object"); + }); + + it("throws for passRate outside 0-1 range", async () => { + const filePath = await writePolicy("bad-rate.json", { + name: "bad", + thresholds: { passRate: 1.5 } + }); + await expect(loadPolicy(filePath)).rejects.toThrow("must be between 0 and 1"); + }); + + it("throws for non-number passRate", async () => { + const filePath = await writePolicy("string-rate.json", { + name: "bad", + thresholds: { passRate: "high" } + }); + await expect(loadPolicy(filePath)).rejects.toThrow("must be a number"); + }); + + it("throws for criteria.disable with non-strings", async () => { + const filePath = await writePolicy("bad-disable.json", { + name: "bad", + criteria: { disable: [1, 2, 3] } + }); + await expect(loadPolicy(filePath)).rejects.toThrow("must be an array of strings"); + }); + + it("rejects criteria.add in JSON policies", async () => { + const filePath = await writePolicy("add-in-json.json", { + name: "bad", + criteria: { add: [{ id: "custom" }] } + }); + await expect(loadPolicy(filePath)).rejects.toThrow("not supported in JSON policies"); + }); + + it("rejects extras.add in JSON policies", async () => { + const filePath = await writePolicy("extras-add-json.json", { + name: "bad", + extras: { add: [{ id: "custom" }] } + }); + await expect(loadPolicy(filePath)).rejects.toThrow("not supported in JSON policies"); + }); + + it("throws for non-existent npm package", async () => { + await expect(loadPolicy("@primer/nonexistent-policy-pkg-12345")).rejects.toThrow("npm install"); + }); + + it("throws for non-object criteria", async () => { + const filePath = await writePolicy("bad-criteria.json", { + name: "bad", + criteria: "not-an-object" + }); + await expect(loadPolicy(filePath)).rejects.toThrow('"criteria" must be an object'); + }); + + it("throws for non-object extras", async () => { + const filePath = await writePolicy("bad-extras.json", { + name: "bad", + extras: 42 + }); + await expect(loadPolicy(filePath)).rejects.toThrow('"extras" must be an object'); + }); + + it("throws for null extras", async () => { + const filePath = await writePolicy("null-extras.json", { + name: "bad", + extras: null + }); + await expect(loadPolicy(filePath)).rejects.toThrow('"extras" must be an object'); + }); + + it("throws for extras.disable with non-strings", async () => { + const filePath = await writePolicy("bad-extras-disable.json", { + name: "bad", + extras: { disable: [1, 2] } + }); + await expect(loadPolicy(filePath)).rejects.toThrow( + '"extras.disable" must be an array of strings' + ); + }); + + it("throws for non-object thresholds", async () => { + const filePath = await writePolicy("bad-thresholds.json", { + name: "bad", + thresholds: "high" + }); + await expect(loadPolicy(filePath)).rejects.toThrow('"thresholds" must be an object'); + }); + + it("throws for non-object criteria.override", async () => { + const filePath = await writePolicy("bad-override.json", { + name: "bad", + criteria: { override: "not-an-object" } + }); + await expect(loadPolicy(filePath)).rejects.toThrow('"criteria.override" must be an object'); + }); + + it("throws for whitespace-only name", async () => { + const filePath = await writePolicy("whitespace-name.json", { + name: " " + }); + await expect(loadPolicy(filePath)).rejects.toThrow('missing required field "name"'); + }); + + it("loads JSON policy via absolute path", async () => { + const filePath = path.join(tmpDir, "abs-policy.json"); + await fs.writeFile(filePath, JSON.stringify({ name: "absolute" }), "utf8"); + const config = await loadPolicy(filePath); + expect(config.name).toBe("absolute"); + }); + + it("rejects override with disallowed key 'id'", async () => { + const filePath = await writePolicy("bad-override-key.json", { + name: "bad", + criteria: { override: { a: { id: "hijacked" } } } + }); + await expect(loadPolicy(filePath)).rejects.toThrow('disallowed key "id"'); + }); + + it("rejects override with disallowed key 'check'", async () => { + const filePath = await writePolicy("bad-override-check.json", { + name: "bad", + criteria: { override: { a: { check: "payload" } } } + }); + await expect(loadPolicy(filePath)).rejects.toThrow('disallowed key "check"'); + }); + + it("allows override with all valid metadata keys", async () => { + const filePath = await writePolicy("good-override.json", { + name: "good", + criteria: { + override: { + a: { + title: "New", + pillar: "testing", + level: 2, + scope: "app", + impact: "high", + effort: "medium" + } + } + } + }); + const config = await loadPolicy(filePath); + expect(config.criteria?.override?.a).toEqual({ + title: "New", + pillar: "testing", + level: 2, + scope: "app", + impact: "high", + effort: "medium" + }); + }); + + it("loads a .mjs module policy", async () => { + const filePath = path.join(tmpDir, "mod-policy.mjs"); + await fs.writeFile(filePath, `export default { name: "mjs-policy", criteria: {} };\n`, "utf8"); + const config = await loadPolicy(filePath); + expect(config.name).toBe("mjs-policy"); + }); +}); + +// ─── loadPolicy jsonOnly mode ─── + +describe("loadPolicy jsonOnly", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "primer-policy-jsononly-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("allows JSON policies when jsonOnly is true", async () => { + const filePath = path.join(tmpDir, "ok.json"); + await fs.writeFile(filePath, JSON.stringify({ name: "ok" }), "utf8"); + const config = await loadPolicy(filePath, { jsonOnly: true }); + expect(config.name).toBe("ok"); + }); + + it("rejects .ts module policies when jsonOnly is true", async () => { + await expect(loadPolicy("./my-policy.ts", { jsonOnly: true })).rejects.toThrow( + "only JSON policies are allowed from primer.config.json" + ); + }); + + it("rejects .js module policies when jsonOnly is true", async () => { + await expect(loadPolicy("./my-policy.js", { jsonOnly: true })).rejects.toThrow( + "only JSON policies are allowed from primer.config.json" + ); + }); + + it("rejects npm package policies when jsonOnly is true", async () => { + await expect(loadPolicy("@org/policy-pkg", { jsonOnly: true })).rejects.toThrow( + "only JSON file policies are allowed from primer.config.json" + ); + }); + + it("rejects .mjs module policies when jsonOnly is true", async () => { + await expect(loadPolicy("./my-policy.mjs", { jsonOnly: true })).rejects.toThrow( + "only JSON policies are allowed from primer.config.json" + ); + }); + + it("rejects .cjs module policies when jsonOnly is true", async () => { + await expect(loadPolicy("./my-policy.cjs", { jsonOnly: true })).rejects.toThrow( + "only JSON policies are allowed from primer.config.json" + ); + }); +}); + +// ─── parsePolicySources ─── + +describe("parsePolicySources", () => { + it("returns undefined for undefined input", () => { + expect(parsePolicySources(undefined)).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(parsePolicySources("")).toBeUndefined(); + }); + + it("splits comma-separated sources and trims whitespace", () => { + expect(parsePolicySources("./a.json, ./b.json , @org/pkg")).toEqual([ + "./a.json", + "./b.json", + "@org/pkg" + ]); + }); + + it("filters out empty segments", () => { + expect(parsePolicySources("./a.json,,./b.json")).toEqual(["./a.json", "./b.json"]); + }); + + it("handles a single source without commas", () => { + expect(parsePolicySources("./a.json")).toEqual(["./a.json"]); + }); +}); diff --git a/src/services/__tests__/pr.test.ts b/src/services/__tests__/pr.test.ts new file mode 100644 index 0000000..fe9d0a2 --- /dev/null +++ b/src/services/__tests__/pr.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { + buildInstructionsPrBody, + buildFullPrBody, + isPrimerFile, + PRIMER_FILE_PATTERNS +} from "../../utils/pr"; + +describe("buildInstructionsPrBody", () => { + it("includes instructions file", () => { + const body = buildInstructionsPrBody(); + + expect(body).toContain("copilot-instructions.md"); + }); + + it("does not include VS Code config files", () => { + const body = buildInstructionsPrBody(); + + expect(body).not.toContain(".vscode/settings.json"); + expect(body).not.toContain(".vscode/mcp.json"); + }); + + it("includes project link", () => { + const body = buildInstructionsPrBody(); + + expect(body).toContain("Primer"); + expect(body).toContain("github.com"); + }); +}); + +describe("buildFullPrBody", () => { + it("includes all three config files", () => { + const body = buildFullPrBody(); + + expect(body).toContain("copilot-instructions.md"); + expect(body).toContain(".vscode/settings.json"); + expect(body).toContain(".vscode/mcp.json"); + }); + + it("includes benefits section", () => { + const body = buildFullPrBody(); + + expect(body).toContain("Benefits"); + expect(body).toContain("MCP"); + }); + + it("includes how to use section", () => { + const body = buildFullPrBody(); + + expect(body).toContain("How to Use"); + expect(body).toContain("VS Code"); + }); + + it("includes markdown table", () => { + const body = buildFullPrBody(); + + expect(body).toContain("| File | Purpose |"); + expect(body).toContain("|------|---------|"); + }); +}); + +describe("isPrimerFile", () => { + it.each([...PRIMER_FILE_PATTERNS])("matches exact pattern: %s", (pattern) => { + expect(isPrimerFile(pattern)).toBe(true); + }); + + it("matches .instructions.md suffix", () => { + expect(isPrimerFile("src/api/.instructions.md")).toBe(true); + expect(isPrimerFile(".instructions.md")).toBe(true); + expect(isPrimerFile("deep/nested/path/.instructions.md")).toBe(true); + }); + + it("rejects unrelated files", () => { + expect(isPrimerFile("package.json")).toBe(false); + expect(isPrimerFile("src/index.ts")).toBe(false); + expect(isPrimerFile("README.md")).toBe(false); + expect(isPrimerFile(".gitignore")).toBe(false); + }); + + it("rejects partial matches", () => { + expect(isPrimerFile("copilot-instructions.md")).toBe(false); + expect(isPrimerFile("mcp.json")).toBe(false); + expect(isPrimerFile("settings.json")).toBe(false); + expect(isPrimerFile(".vscode/extensions.json")).toBe(false); + }); + + it("rejects files that contain pattern as substring", () => { + expect(isPrimerFile("old/.github/copilot-instructions.md.bak")).toBe(false); + expect(isPrimerFile("backup/AGENTS.md")).toBe(false); + expect(isPrimerFile("other/.vscode/mcp.json")).toBe(false); + }); + + it("rejects instructions.md without dot prefix", () => { + expect(isPrimerFile("instructions.md")).toBe(false); + expect(isPrimerFile("src/instructions.md")).toBe(false); + }); + + it("matches Windows-style backslash paths", () => { + expect(isPrimerFile(".github\\copilot-instructions.md")).toBe(true); + expect(isPrimerFile(".vscode\\mcp.json")).toBe(true); + expect(isPrimerFile(".vscode\\settings.json")).toBe(true); + expect(isPrimerFile("src\\api\\.instructions.md")).toBe(true); + }); +}); diff --git a/src/services/__tests__/readiness-baseline.test.ts b/src/services/__tests__/readiness-baseline.test.ts new file mode 100644 index 0000000..121e510 --- /dev/null +++ b/src/services/__tests__/readiness-baseline.test.ts @@ -0,0 +1,211 @@ +/** + * Phase C: Characterization baselines. + * + * These tests lock the existing ReadinessReport shape, pillar/level summaries, + * and command output behavior. They serve as the parity gate before the new + * plugin engine is wired into the readiness pipeline (Phase G). + * + * If any of these fail after engine integration, the compatibility adapter + * is incorrect or incomplete. + */ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runReadinessReport, buildCriteria, buildExtras, groupPillars } from "../readiness"; + +describe("ReadinessReport shape baseline", () => { + let repoPath: string; + + beforeEach(async () => { + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "primer-baseline-")); + await fs.writeFile( + path.join(repoPath, "package.json"), + JSON.stringify({ name: "baseline-repo", scripts: { build: "tsc", test: "vitest" } }) + ); + }); + + afterEach(async () => { + await fs.rm(repoPath, { recursive: true, force: true }); + }); + + it("locks the top-level report fields", async () => { + const report = await runReadinessReport({ repoPath }); + const keys = Object.keys(report).sort(); + expect(keys).toEqual([ + "achievedLevel", + "apps", + "areaReports", + "criteria", + "engine", + "extras", + "generatedAt", + "isMonorepo", + "levels", + "pillars", + "policies", + "repoPath" + ]); + }); + + it("locks ReadinessCriterionResult field set", async () => { + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria[0]; + expect(criterion).toHaveProperty("id"); + expect(criterion).toHaveProperty("title"); + expect(criterion).toHaveProperty("pillar"); + expect(criterion).toHaveProperty("level"); + expect(criterion).toHaveProperty("scope"); + expect(criterion).toHaveProperty("impact"); + expect(criterion).toHaveProperty("effort"); + expect(criterion).toHaveProperty("status"); + }); + + it("locks pillar summary fields", async () => { + const report = await runReadinessReport({ repoPath }); + const pillar = report.pillars[0]; + expect(pillar).toHaveProperty("id"); + expect(pillar).toHaveProperty("name"); + expect(pillar).toHaveProperty("passed"); + expect(pillar).toHaveProperty("total"); + expect(pillar).toHaveProperty("passRate"); + }); + + it("locks level summary fields", async () => { + const report = await runReadinessReport({ repoPath }); + const level = report.levels[0]; + expect(level).toHaveProperty("level"); + expect(level).toHaveProperty("name"); + expect(level).toHaveProperty("passed"); + expect(level).toHaveProperty("total"); + expect(level).toHaveProperty("passRate"); + expect(level).toHaveProperty("achieved"); + }); + + it("locks the allowed ReadinessStatus values", async () => { + const report = await runReadinessReport({ repoPath }); + const statuses = new Set(report.criteria.map((c) => c.status)); + // Every status must be one of the 3 allowed values + for (const status of statuses) { + expect(["pass", "fail", "skip"]).toContain(status); + } + }); + + it("locks the 9 pillar IDs", async () => { + const report = await runReadinessReport({ repoPath }); + const pillarIds = report.pillars.map((p) => p.id).sort(); + expect(pillarIds).toEqual([ + "ai-tooling", + "build-system", + "code-quality", + "dev-environment", + "documentation", + "observability", + "security-governance", + "style-validation", + "testing" + ]); + }); + + it("locks the 5 maturity level names", async () => { + const report = await runReadinessReport({ repoPath }); + const levelNames = report.levels.map((l) => l.name); + expect(levelNames).toEqual([ + "Functional", + "Documented", + "Standardized", + "Optimized", + "Autonomous" + ]); + }); +}); + +describe("countStatus baseline", () => { + it("excludes skip items from total and passed counts", async () => { + const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "primer-cs-")); + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify({ name: "count-repo" })); + + try { + const report = await runReadinessReport({ repoPath }); + // Verify skips don't inflate totals + for (const pillar of report.pillars) { + const criteria = report.criteria.filter((c) => c.pillar === pillar.id); + const nonSkip = criteria.filter((c) => c.status !== "skip"); + expect(pillar.total).toBe(nonSkip.length); + } + } finally { + await fs.rm(repoPath, { recursive: true, force: true }); + } + }); +}); + +describe("groupPillars baseline", () => { + it("groups pillars into repo-health and ai-setup", async () => { + const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "primer-gp-")); + await fs.writeFile(path.join(repoPath, "package.json"), JSON.stringify({ name: "group-repo" })); + + try { + const report = await runReadinessReport({ repoPath }); + const groups = groupPillars(report.pillars); + expect(groups).toHaveLength(2); + expect(groups[0].group).toBe("repo-health"); + expect(groups[0].label).toBe("Repo Health"); + expect(groups[1].group).toBe("ai-setup"); + expect(groups[1].label).toBe("AI Setup"); + // ai-tooling pillar is in the ai-setup group + expect(groups[1].pillars.map((p) => p.id)).toContain("ai-tooling"); + } finally { + await fs.rm(repoPath, { recursive: true, force: true }); + } + }); +}); + +describe("buildCriteria baseline", () => { + it("locks the set of built-in criterion IDs", () => { + const criteria = buildCriteria(); + const ids = criteria.map((c) => c.id).sort(); + expect(ids).toEqual([ + "area-build-script", + "area-instructions", + "area-readme", + "area-test-script", + "build-script", + "ci-config", + "codeowners", + "contributing", + "copilot-skills", + "custom-agents", + "custom-instructions", + "dependabot", + "env-example", + "format-config", + "license", + "lint-config", + "lockfile", + "mcp-config", + "observability", + "readme", + "security-policy", + "test-script", + "typecheck-config" + ]); + }); + + it("locks the impact and effort allowed values", () => { + const criteria = buildCriteria(); + for (const c of criteria) { + expect(["high", "medium", "low"]).toContain(c.impact); + expect(["low", "medium", "high"]).toContain(c.effort); + } + }); +}); + +describe("buildExtras baseline", () => { + it("locks the set of built-in extra IDs", () => { + const extras = buildExtras(); + const ids = extras.map((e) => e.id).sort(); + expect(ids).toEqual(["agents-doc", "architecture-doc", "pr-template", "pre-commit"]); + }); +}); diff --git a/src/services/__tests__/readiness-markdown.test.ts b/src/services/__tests__/readiness-markdown.test.ts new file mode 100644 index 0000000..459a7c1 --- /dev/null +++ b/src/services/__tests__/readiness-markdown.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { formatReadinessMarkdown } from "../../commands/readiness"; +import type { ReadinessReport } from "../readiness"; + +describe("formatReadinessMarkdown", () => { + function makeReport(overrides: Partial = {}): ReadinessReport { + return { + repoPath: "/tmp/test-repo", + generatedAt: "2026-01-01T00:00:00.000Z", + isMonorepo: false, + apps: [], + pillars: [ + { id: "style-validation", name: "Style & Validation", passed: 2, total: 2, passRate: 1 }, + { id: "build-system", name: "Build System", passed: 1, total: 2, passRate: 0.5 }, + { id: "testing", name: "Testing", passed: 0, total: 1, passRate: 0 }, + { id: "documentation", name: "Documentation", passed: 1, total: 2, passRate: 0.5 }, + { id: "dev-environment", name: "Dev Environment", passed: 1, total: 2, passRate: 0.5 }, + { id: "code-quality", name: "Code Quality", passed: 1, total: 1, passRate: 1 }, + { id: "observability", name: "Observability", passed: 0, total: 1, passRate: 0 }, + { + id: "security-governance", + name: "Security & Governance", + passed: 2, + total: 4, + passRate: 0.5 + }, + { id: "ai-tooling", name: "AI Tooling", passed: 1, total: 4, passRate: 0.25 } + ], + levels: [ + { level: 1, name: "Functional", passed: 5, total: 6, passRate: 0.83, achieved: true }, + { level: 2, name: "Documented", passed: 3, total: 6, passRate: 0.5, achieved: false }, + { level: 3, name: "Standardized", passed: 1, total: 4, passRate: 0.25, achieved: false }, + { level: 4, name: "Optimized", passed: 0, total: 0, passRate: 0, achieved: false }, + { level: 5, name: "Autonomous", passed: 0, total: 0, passRate: 0, achieved: false } + ], + achievedLevel: 1, + criteria: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + }, + { + id: "readme", + title: "README present", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "fail", + reason: "Missing README documentation." + }, + { + id: "custom-instructions", + title: "Custom AI instructions", + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "fail", + reason: "Missing custom AI instructions." + } + ], + extras: [ + { id: "agents-doc", title: "AGENTS.md present", status: "pass" }, + { id: "architecture-doc", title: "Architecture guide present", status: "fail" } + ], + ...overrides + }; + } + + it("renders heading with repo name and level", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("# AI Readiness Report: my-repo"); + expect(md).toContain("**Level 1** — Functional"); + }); + + it("includes pillar group sections", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("## Repo Health"); + expect(md).toContain("## AI Setup"); + }); + + it("renders pillar summary table", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("| Pillar | Passed | Total | Rate |"); + expect(md).toContain("Style & Validation"); + expect(md).toContain("AI Tooling"); + }); + + it("uses check emoji for passing pillars", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + // Style & Validation has 100% pass rate + expect(md).toMatch(/✅.*Style & Validation/); + }); + + it("uses warning emoji for low-pass pillars", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + // Build System has 50% pass rate + expect(md).toMatch(/⚠️.*Build System/); + }); + + it("includes fix-first section for failing criteria", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("## Fix First"); + expect(md).toContain("README present"); + expect(md).toContain("Custom AI instructions"); + }); + + it("includes extras section", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("## AI Readiness Extras"); + expect(md).toContain("✅ AGENTS.md present"); + expect(md).toContain("❌ Architecture guide present"); + }); + + it("includes area breakdown when present", () => { + const md = formatReadinessMarkdown( + makeReport({ + areaReports: [ + { + area: { name: "frontend", applyTo: "frontend/**", source: "auto" }, + criteria: [ + { + id: "area-readme", + title: "Area README present", + pillar: "documentation", + level: 1, + scope: "area", + impact: "medium", + effort: "low", + status: "fail", + reason: "Missing README in area directory." + } + ], + pillars: [ + { + id: "documentation", + name: "Documentation", + passed: 0, + total: 1, + passRate: 0 + } + ] + } + ] + }), + "my-repo" + ); + expect(md).toContain("## Per-Area Breakdown"); + expect(md).toContain("### frontend"); + expect(md).toContain("❌ Area README present"); + }); + + it("includes primer footer with timestamp", () => { + const md = formatReadinessMarkdown(makeReport(), "my-repo"); + expect(md).toContain("Primer"); + expect(md).toContain("2026-01-01"); + }); + + it("handles report with no failing criteria", () => { + const md = formatReadinessMarkdown( + makeReport({ + criteria: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + } + ] + }), + "my-repo" + ); + expect(md).not.toContain("## Fix First"); + }); +}); diff --git a/src/services/__tests__/readiness-output.test.ts b/src/services/__tests__/readiness-output.test.ts new file mode 100644 index 0000000..1b8e532 --- /dev/null +++ b/src/services/__tests__/readiness-output.test.ts @@ -0,0 +1,241 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { readinessCommand } from "../../commands/readiness"; + +describe("readinessCommand --output", () => { + let tmpDir: string | undefined; + + async function setupRepo(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "readiness-output-")); + const repoPath = path.join(tmpDir, "repo"); + await fs.mkdir(repoPath); + return repoPath; + } + + afterEach(async () => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } + }); + + it("writes JSON file when output ends in .json", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + const parsed = JSON.parse(content); + expect(parsed.repoPath).toBe(repoPath); + expect(parsed).toHaveProperty("achievedLevel"); + }); + + it("writes Markdown file for uppercase extension", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.MD"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toContain("# AI Readiness Report:"); + expect(content).toContain("## Repo Health"); + }); + + it("writes HTML file for uppercase extension", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.HTML"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toContain(""); + }); + + it("writes default visual HTML file when --visual is used without --output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(repoPath, "readiness-report.html"); + + await readinessCommand(repoPath, { visual: true, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toContain(""); + }); + + it("rejects unsupported extensions before visual rendering", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.txt"); + const fallbackPath = path.join(repoPath, "readiness-report.html"); + + await readinessCommand(repoPath, { output: outputPath, visual: true, quiet: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.access(outputPath)).rejects.toThrow(); + await expect(fs.access(fallbackPath)).rejects.toThrow(); + }); + + it("rejects --visual with non-HTML output extension", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + const fallbackPath = path.join(repoPath, "readiness-report.html"); + + await readinessCommand(repoPath, { output: outputPath, visual: true, quiet: true }); + + expect(process.exitCode).toBe(1); + await expect(fs.access(outputPath)).rejects.toThrow(); + await expect(fs.access(fallbackPath)).rejects.toThrow(); + }); + + it("refuses to overwrite without --force", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + await fs.writeFile(outputPath, "existing"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).toBe("existing"); + }); + + it("overwrites with --force", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + await fs.writeFile(outputPath, "existing"); + + await readinessCommand(repoPath, { output: outputPath, force: true, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(content).not.toBe("existing"); + expect(JSON.parse(content).repoPath).toBe(repoPath); + }); + + it("rejects symlink paths", async () => { + const repoPath = await setupRepo(); + const realPath = path.join(tmpDir ?? repoPath, "real.json"); + const linkPath = path.join(tmpDir ?? repoPath, "readiness.json"); + await fs.writeFile(realPath, "existing"); + await fs.symlink(realPath, linkPath); + + await readinessCommand(repoPath, { output: linkPath, quiet: true }); + + const content = await fs.readFile(realPath, "utf-8"); + expect(content).toBe("existing"); + }); + + it("sets exit code when fail-level threshold is not met", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + + await readinessCommand(repoPath, { output: outputPath, failLevel: "5", quiet: true }); + + expect(process.exitCode).toBe(1); + const content = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(content).repoPath).toBe(repoPath); + }); + + it("creates parent directories for nested output paths", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "reports", "nested", "readiness.json"); + + await readinessCommand(repoPath, { output: outputPath, quiet: true }); + + const content = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(content).repoPath).toBe(repoPath); + }); + + it("emits JSON to stdout when --json is used with --output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { output: outputPath, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(fileContent).repoPath).toBe(repoPath); + }); + + it("emits JSON to stdout when --json is used with markdown output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.md"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { output: outputPath, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(fileContent).toContain("# AI Readiness Report:"); + }); + + it("emits JSON to stdout when --json is used with html output", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.html"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { output: outputPath, json: true, quiet: true }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { ok: boolean; status: string; data: unknown }; + expect(parsed.ok).toBe(true); + expect(parsed.status).toBe("success"); + expect(parsed.data).toBeDefined(); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(fileContent).toContain(""); + }); + + it("emits error JSON status when fail-level threshold is not met", async () => { + const repoPath = await setupRepo(); + const outputPath = path.join(tmpDir ?? repoPath, "readiness.json"); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await readinessCommand(repoPath, { + output: outputPath, + json: true, + quiet: true, + failLevel: "5" + }); + + const stdout = stdoutSpy.mock.calls + .map(([chunk]) => String(chunk)) + .join("") + .trim(); + const parsed = JSON.parse(stdout) as { + ok: boolean; + status: string; + errors?: string[]; + }; + expect(parsed.ok).toBe(false); + expect(parsed.status).toBe("error"); + expect(parsed.errors?.[0]).toContain("below threshold"); + expect(process.exitCode).toBe(1); + + const fileContent = await fs.readFile(outputPath, "utf-8"); + expect(JSON.parse(fileContent).repoPath).toBe(repoPath); + }); +}); diff --git a/src/services/__tests__/readiness.test.ts b/src/services/__tests__/readiness.test.ts new file mode 100644 index 0000000..d13eb94 --- /dev/null +++ b/src/services/__tests__/readiness.test.ts @@ -0,0 +1,680 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runReadinessReport } from "../readiness"; + +describe("runReadinessReport", () => { + let repoPath: string; + + beforeEach(async () => { + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "primer-readiness-")); + }); + + afterEach(async () => { + await fs.rm(repoPath, { recursive: true, force: true }); + }); + + async function writeFile(relativePath: string, content: string): Promise { + const fullPath = path.join(repoPath, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, "utf8"); + } + + async function writePackageJson(pkg: Record): Promise { + await writeFile("package.json", JSON.stringify(pkg, null, 2)); + } + + it("returns a valid report structure", async () => { + await writePackageJson({ name: "test-repo", scripts: { build: "tsc", test: "vitest" } }); + const report = await runReadinessReport({ repoPath }); + + expect(report.repoPath).toBe(repoPath); + expect(report.generatedAt).toBeTruthy(); + expect(report.pillars).toBeInstanceOf(Array); + expect(report.levels).toBeInstanceOf(Array); + expect(report.criteria).toBeInstanceOf(Array); + expect(typeof report.achievedLevel).toBe("number"); + }); + + it("has all expected pillars", async () => { + await writePackageJson({ name: "test-repo" }); + const report = await runReadinessReport({ repoPath }); + + const pillarIds = report.pillars.map((p) => p.id); + expect(pillarIds).toContain("style-validation"); + expect(pillarIds).toContain("build-system"); + expect(pillarIds).toContain("testing"); + expect(pillarIds).toContain("documentation"); + expect(pillarIds).toContain("dev-environment"); + expect(pillarIds).toContain("code-quality"); + expect(pillarIds).toContain("observability"); + expect(pillarIds).toContain("security-governance"); + expect(pillarIds).toContain("ai-tooling"); + }); + + it("has 5 maturity levels", async () => { + await writePackageJson({ name: "test-repo" }); + const report = await runReadinessReport({ repoPath }); + + expect(report.levels).toHaveLength(5); + expect(report.levels.map((l) => l.level)).toEqual([1, 2, 3, 4, 5]); + }); + + describe("style-validation pillar", () => { + it("passes lint-config when eslint.config.js exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("eslint.config.js", "export default [];"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "lint-config"); + + expect(criterion?.status).toBe("pass"); + }); + + it("fails lint-config when no lint config exists", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "lint-config"); + + expect(criterion?.status).toBe("fail"); + }); + + it("passes typecheck-config when tsconfig.json exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("tsconfig.json", "{}"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "typecheck-config"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("documentation pillar", () => { + it("passes readme when README.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("README.md", "# Test"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "readme"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes contributing when CONTRIBUTING.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("CONTRIBUTING.md", "# Contributing"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "contributing"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("dev-environment pillar", () => { + it("passes lockfile when package-lock.json exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("package-lock.json", "{}"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "lockfile"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes env-example when .env.example exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".env.example", "API_KEY=your-key"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "env-example"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("security-governance pillar", () => { + it("passes codeowners when CODEOWNERS exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("CODEOWNERS", "* @owner"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "codeowners"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes codeowners when .github/CODEOWNERS exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/CODEOWNERS", "* @owner"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "codeowners"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes license when LICENSE exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("LICENSE", "MIT License"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "license"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes security-policy when SECURITY.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("SECURITY.md", "# Security"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "security-policy"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes dependabot when .github/dependabot.yml exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/dependabot.yml", "version: 2"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "dependabot"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("ai-tooling pillar", () => { + it("passes custom-instructions when copilot-instructions.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/copilot-instructions.md", "# Instructions"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-instructions when CLAUDE.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("CLAUDE.md", "# Claude instructions"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-instructions when AGENTS.md exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("AGENTS.md", "# Agents guidance"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-instructions when .cursorrules exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".cursorrules", "rules here"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + }); + + it("mentions missing file-based instructions when areas detected", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/copilot-instructions.md", "# Instructions"); + // Create a heuristic area directory + await writeFile("frontend/index.ts", "export {};"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + expect(criterion?.reason).toContain("no file-based instructions"); + expect(criterion?.evidence).toEqual( + expect.arrayContaining([expect.stringContaining("missing .instructions.md")]) + ); + }); + + it("reports file-based instructions count when present with areas", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/copilot-instructions.md", "# Instructions"); + // Create a heuristic area directory + await writeFile("frontend/index.ts", "export {};"); + // Create a file-based instruction + await writeFile( + ".github/instructions/frontend.instructions.md", + "---\napplyTo: frontend/**\n---\n# Frontend" + ); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("pass"); + expect(criterion?.reason).toContain("file-based instruction"); + expect(criterion?.evidence).toEqual( + expect.arrayContaining([expect.stringContaining("frontend.instructions.md")]) + ); + }); + + it("fails custom-instructions when none exist", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-instructions"); + + expect(criterion?.status).toBe("fail"); + }); + + it("passes mcp-config when .vscode/mcp.json exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".vscode/mcp.json", "{}"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "mcp-config"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes mcp-config when .vscode/settings.json has mcp key", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile( + ".vscode/settings.json", + JSON.stringify({ "github.copilot.chat.mcp.enabled": true }) + ); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "mcp-config"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes custom-agents when .github/agents directory exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/agents/.gitkeep", ""); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "custom-agents"); + + expect(criterion?.status).toBe("pass"); + }); + + it("passes copilot-skills when .copilot/skills directory exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".copilot/skills/.gitkeep", ""); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "copilot-skills"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("build-system pillar", () => { + it("passes ci-config when .github/workflows exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile(".github/workflows/ci.yml", "name: CI"); + + const report = await runReadinessReport({ repoPath }); + const criterion = report.criteria.find((c) => c.id === "ci-config"); + + expect(criterion?.status).toBe("pass"); + }); + }); + + describe("achieved level", () => { + it("achieves level 1 with basic setup", async () => { + await writePackageJson({ + name: "test-repo", + scripts: { build: "tsc", test: "vitest" } + }); + await writeFile("eslint.config.js", "export default [];"); + await writeFile("README.md", "# Test"); + await writeFile("package-lock.json", "{}"); + await writeFile("LICENSE", "MIT"); + + const report = await runReadinessReport({ repoPath }); + + expect(report.achievedLevel).toBeGreaterThanOrEqual(1); + }); + + it("is 0 for an empty repo", async () => { + await writePackageJson({ name: "empty-repo" }); + + const report = await runReadinessReport({ repoPath }); + + // Level 0 means nothing achieved (most L1 checks fail) + expect(report.achievedLevel).toBeLessThanOrEqual(1); + }); + }); + + describe("pillar summaries", () => { + it("calculates passRate correctly", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("eslint.config.js", "export default [];"); + + const report = await runReadinessReport({ repoPath }); + const stylePillar = report.pillars.find((p) => p.id === "style-validation"); + + expect(stylePillar).toBeDefined(); + expect(stylePillar!.passed).toBeGreaterThanOrEqual(1); + expect(stylePillar!.total).toBeGreaterThanOrEqual(1); + expect(stylePillar!.passRate).toBe(stylePillar!.passed / stylePillar!.total); + }); + }); + + describe("extras", () => { + it("includes extras by default", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath }); + + expect(report.extras.length).toBeGreaterThan(0); + const extraIds = report.extras.map((e) => e.id); + expect(extraIds).toContain("pr-template"); + expect(extraIds).toContain("pre-commit"); + expect(extraIds).toContain("architecture-doc"); + }); + + it("excludes extras when disabled", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath, includeExtras: false }); + + expect(report.extras).toHaveLength(0); + }); + }); + + describe("per-area readiness", () => { + it("returns areaReports when perArea is true and areas exist", async () => { + await writePackageJson({ name: "test-repo" }); + // Create two heuristic areas with meaningful content + await writeFile("frontend/index.ts", "export {};"); + await writeFile( + "backend/package.json", + JSON.stringify({ name: "backend", scripts: { build: "tsc", test: "vitest" } }) + ); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + expect(report.areaReports).toBeDefined(); + expect(report.areaReports!.length).toBe(2); + const areaNames = report.areaReports!.map((ar) => ar.area.name).sort(); + expect(areaNames).toContain("frontend"); + expect(areaNames).toContain("backend"); + }); + + it("does not return areaReports when perArea is false", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + + const report = await runReadinessReport({ repoPath, perArea: false }); + + expect(report.areaReports).toBeUndefined(); + }); + + it("does not return areaReports when no areas exist", async () => { + await writePackageJson({ name: "test-repo" }); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + expect(report.areaReports).toBeUndefined(); + }); + + it("passes area-readme when area has README.md", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + await writeFile("frontend/README.md", "# Frontend"); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const frontendReport = report.areaReports!.find((ar) => ar.area.name === "frontend"); + const readmeCriterion = frontendReport!.criteria.find((c) => c.id === "area-readme"); + expect(readmeCriterion?.status).toBe("pass"); + }); + + it("fails area-readme when area has no README", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const frontendReport = report.areaReports!.find((ar) => ar.area.name === "frontend"); + const readmeCriterion = frontendReport!.criteria.find((c) => c.id === "area-readme"); + expect(readmeCriterion?.status).toBe("fail"); + }); + + it("passes area-build-script when area has build script", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile( + "backend/package.json", + JSON.stringify({ name: "backend", scripts: { build: "tsc" } }) + ); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const backendReport = report.areaReports!.find((ar) => ar.area.name === "backend"); + const buildCriterion = backendReport!.criteria.find((c) => c.id === "area-build-script"); + expect(buildCriterion?.status).toBe("pass"); + }); + + it("fails area-build-script when area has no build script", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const frontendReport = report.areaReports!.find((ar) => ar.area.name === "frontend"); + const buildCriterion = frontendReport!.criteria.find((c) => c.id === "area-build-script"); + expect(buildCriterion?.status).toBe("fail"); + }); + + it("passes area-test-script when area has test script", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile( + "backend/package.json", + JSON.stringify({ name: "backend", scripts: { test: "vitest" } }) + ); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const backendReport = report.areaReports!.find((ar) => ar.area.name === "backend"); + const testCriterion = backendReport!.criteria.find((c) => c.id === "area-test-script"); + expect(testCriterion?.status).toBe("pass"); + }); + + it("passes area-instructions when matching instruction file exists", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + await writeFile( + ".github/instructions/frontend.instructions.md", + "---\napplyTo: frontend/**\n---\n# Frontend" + ); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const frontendReport = report.areaReports!.find((ar) => ar.area.name === "frontend"); + const instrCriterion = frontendReport!.criteria.find((c) => c.id === "area-instructions"); + expect(instrCriterion?.status).toBe("pass"); + }); + + it("fails area-instructions when no matching instruction file", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const frontendReport = report.areaReports!.find((ar) => ar.area.name === "frontend"); + const instrCriterion = frontendReport!.criteria.find((c) => c.id === "area-instructions"); + expect(instrCriterion?.status).toBe("fail"); + }); + + it("includes aggregate area criteria in main criteria list", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + await writeFile("frontend/README.md", "# Frontend"); + await writeFile("backend/package.json", JSON.stringify({ name: "backend" })); + await writeFile("backend/README.md", "# Backend"); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const areaReadme = report.criteria.find((c) => c.id === "area-readme"); + expect(areaReadme).toBeDefined(); + expect(areaReadme!.scope).toBe("area"); + expect(areaReadme!.areaSummary).toBeDefined(); + expect(areaReadme!.areaSummary!.passed).toBe(2); + expect(areaReadme!.areaSummary!.total).toBe(2); + expect(areaReadme!.status).toBe("pass"); + }); + + it("area criteria excluded when perArea is false", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + + const report = await runReadinessReport({ repoPath }); + + const areaReadme = report.criteria.find((c) => c.id === "area-readme"); + expect(areaReadme).toBeUndefined(); + }); + + it("aggregate passes at exactly 80% threshold", async () => { + await writePackageJson({ name: "test-repo" }); + // 5 areas: 4 with README (80%) and 1 without + await writeFile("frontend/index.ts", "export {};"); + await writeFile("frontend/README.md", "# Frontend"); + await writeFile("backend/package.json", JSON.stringify({ name: "backend" })); + await writeFile("backend/README.md", "# Backend"); + await writeFile("api/package.json", JSON.stringify({ name: "api" })); + await writeFile("api/README.md", "# API"); + await writeFile("server/package.json", JSON.stringify({ name: "server" })); + await writeFile("server/README.md", "# Server"); + await writeFile("client/index.ts", "export {};"); + // client has no README + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const areaReadme = report.criteria.find((c) => c.id === "area-readme"); + expect(areaReadme!.areaSummary!.passed).toBe(4); + expect(areaReadme!.areaSummary!.total).toBe(5); + expect(areaReadme!.status).toBe("pass"); // 4/5 = 80% >= 0.8 + }); + + it("aggregate fails below 80% threshold", async () => { + await writePackageJson({ name: "test-repo" }); + // 5 areas: 3 with README (60%) and 2 without + await writeFile("frontend/index.ts", "export {};"); + await writeFile("frontend/README.md", "# Frontend"); + await writeFile("backend/package.json", JSON.stringify({ name: "backend" })); + await writeFile("backend/README.md", "# Backend"); + await writeFile("api/package.json", JSON.stringify({ name: "api" })); + await writeFile("api/README.md", "# API"); + await writeFile("server/package.json", JSON.stringify({ name: "server" })); + // server has no README + await writeFile("client/index.ts", "export {};"); + // client has no README + + const report = await runReadinessReport({ repoPath, perArea: true }); + + const areaReadme = report.criteria.find((c) => c.id === "area-readme"); + expect(areaReadme!.areaSummary!.passed).toBe(3); + expect(areaReadme!.areaSummary!.total).toBe(5); + expect(areaReadme!.status).toBe("fail"); // 3/5 = 60% < 0.8 + expect(areaReadme!.areaFailures).toContain("server"); + expect(areaReadme!.areaFailures).toContain("client"); + }); + + it("pillars reflect area aggregate results with --per-area", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("frontend/index.ts", "export {};"); + await writeFile("frontend/README.md", "# Frontend"); + + const report = await runReadinessReport({ repoPath, perArea: true }); + + // area-readme passes (1/1 = 100%), so documentation pillar should count it + const docPillar = report.pillars.find((p) => p.id === "documentation"); + expect(docPillar).toBeDefined(); + const areaReadme = report.criteria.find((c) => c.id === "area-readme"); + expect(areaReadme!.status).toBe("pass"); + // Pillar should include this pass in its count + expect(docPillar!.passed).toBeGreaterThanOrEqual(1); + }); + }); + + describe("policy integration", () => { + it("disables a criterion via JSON policy", async () => { + await writePackageJson({ name: "test-repo" }); + // Write a JSON policy that disables lint-config + const policyPath = path.join(repoPath, "test-policy.json"); + await fs.writeFile( + policyPath, + JSON.stringify({ + name: "test-policy", + criteria: { disable: ["lint-config"] } + }), + "utf8" + ); + + const report = await runReadinessReport({ repoPath, policies: [policyPath] }); + + expect(report.criteria.find((c) => c.id === "lint-config")).toBeUndefined(); + expect(report.policies).toBeDefined(); + expect(report.policies!.chain).toEqual(["test-policy"]); + expect(report.policies!.criteriaCount).toBeGreaterThan(0); + }); + + it("overrides passRate threshold via policy", async () => { + await writePackageJson({ name: "test-repo" }); + const policyPath = path.join(repoPath, "threshold-policy.json"); + await fs.writeFile( + policyPath, + JSON.stringify({ + name: "strict", + thresholds: { passRate: 1.0 } + }), + "utf8" + ); + + const report = await runReadinessReport({ repoPath, policies: [policyPath] }); + + expect(report.policies!.chain).toEqual(["strict"]); + }); + + it("falls back to primer.config.json policies", async () => { + await writePackageJson({ name: "test-repo" }); + // Write a policy file using absolute path + const policyPath = path.join(repoPath, "config-policy.json"); + await fs.writeFile( + policyPath, + JSON.stringify({ name: "from-config", criteria: { disable: ["readme"] } }), + "utf8" + ); + // Reference it from primer.config.json with absolute path + await writeFile("primer.config.json", JSON.stringify({ policies: [policyPath] })); + + const report = await runReadinessReport({ repoPath }); + + expect(report.policies!.chain).toEqual(["from-config"]); + expect(report.criteria.find((c) => c.id === "readme")).toBeUndefined(); + }); + + it("rejects module policies from primer.config.json", async () => { + await writePackageJson({ name: "test-repo" }); + await writeFile("primer.config.json", JSON.stringify({ policies: ["./my-policy.ts"] })); + + await expect(runReadinessReport({ repoPath })).rejects.toThrow( + "only JSON policies are allowed from primer.config.json" + ); + }); + }); +}); diff --git a/src/services/__tests__/visualReport.test.ts b/src/services/__tests__/visualReport.test.ts new file mode 100644 index 0000000..6637704 --- /dev/null +++ b/src/services/__tests__/visualReport.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; + +import type { ReadinessReport } from "../readiness"; +import { generateVisualReport } from "../visualReport"; + +function makeReport(overrides: Partial = {}): ReadinessReport { + return { + repoPath: "/tmp/test-repo", + generatedAt: "2026-01-01T00:00:00.000Z", + isMonorepo: false, + apps: [], + pillars: [ + { id: "style-validation", name: "Style & Validation", passed: 2, total: 2, passRate: 1 }, + { id: "build-system", name: "Build System", passed: 1, total: 2, passRate: 0.5 }, + { id: "testing", name: "Testing", passed: 0, total: 1, passRate: 0 }, + { id: "documentation", name: "Documentation", passed: 1, total: 2, passRate: 0.5 }, + { id: "dev-environment", name: "Dev Environment", passed: 1, total: 2, passRate: 0.5 }, + { id: "code-quality", name: "Code Quality", passed: 1, total: 1, passRate: 1 }, + { id: "observability", name: "Observability", passed: 0, total: 1, passRate: 0 }, + { + id: "security-governance", + name: "Security & Governance", + passed: 2, + total: 4, + passRate: 0.5 + }, + { id: "ai-tooling", name: "AI Tooling", passed: 1, total: 4, passRate: 0.25 } + ], + levels: [ + { level: 1, name: "Functional", passed: 5, total: 6, passRate: 0.83, achieved: true }, + { level: 2, name: "Documented", passed: 3, total: 6, passRate: 0.5, achieved: false }, + { level: 3, name: "Standardized", passed: 1, total: 4, passRate: 0.25, achieved: false }, + { level: 4, name: "Optimized", passed: 0, total: 0, passRate: 0, achieved: false }, + { level: 5, name: "Autonomous", passed: 0, total: 0, passRate: 0, achieved: false } + ], + achievedLevel: 1, + criteria: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + }, + { + id: "readme", + title: "README present", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + }, + { + id: "custom-instructions", + title: "Custom AI instructions", + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + }, + { + id: "mcp-config", + title: "MCP config present", + pillar: "ai-tooling", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + status: "fail", + reason: "Missing MCP config." + } + ], + extras: [], + ...overrides + }; +} + +describe("generateVisualReport", () => { + it("returns valid HTML", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("includes the report title", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }], + title: "My Custom Report" + }); + + expect(html).toContain("My Custom Report"); + }); + + it("includes repo name", () => { + const html = generateVisualReport({ + reports: [{ repo: "my-repo", report: makeReport() }] + }); + + expect(html).toContain("my-repo"); + }); + + it("includes pillar names", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain("Style & Validation"); + expect(html).toContain("Build System"); + expect(html).toContain("AI Tooling"); + }); + + it("includes maturity level badge", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport({ achievedLevel: 2 }) }] + }); + + expect(html).toContain("Maturity 2"); + expect(html).toContain("Documented"); + }); + + it("includes AI Tooling Readiness hero section", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain("AI Tooling Readiness"); + }); + + it("includes maturity model descriptions", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain("Functional"); + expect(html).toContain("Documented"); + expect(html).toContain("Standardized"); + expect(html).toContain("Optimized"); + expect(html).toContain("Autonomous"); + }); + + it("includes theme toggle", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain("toggleTheme"); + expect(html).toContain("data-theme"); + }); + + it("includes light theme CSS variables", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain('[data-theme="light"]'); + expect(html).toContain('[data-theme="dark"]'); + }); + + it("includes GitHub logo SVG", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain("header-logo"); + expect(html).toContain(" { + const html = generateVisualReport({ + reports: [ + { repo: "good-repo", report: makeReport() }, + { repo: "bad-repo", report: makeReport(), error: "Clone failed" } + ] + }); + + expect(html).toContain("bad-repo"); + expect(html).toContain("Clone failed"); + }); + + it("shows summary cards with correct counts", () => { + const html = generateVisualReport({ + reports: [ + { repo: "repo-1", report: makeReport() }, + { repo: "repo-2", report: makeReport() } + ] + }); + + // Total repos should be 2 + expect(html).toContain(">2<"); + }); + + it("includes top fixes for failing criteria", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).toContain("Top Fixes"); + expect(html).toContain("MCP config present"); + }); + + it("shows all criteria passing when all pass", () => { + const report = makeReport({ + criteria: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + status: "pass" + } + ] + }); + + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report }] + }); + + expect(html).toContain("All criteria passing"); + }); + + it("escapes HTML in repo names", () => { + const html = generateVisualReport({ + reports: [{ repo: '', report: makeReport() }] + }); + + expect(html).not.toContain(''); + expect(html).toContain("<script>"); + }); + + it("renders per-area breakdown when areaReports provided", () => { + const report = makeReport({ + areaReports: [ + { + area: { name: "frontend", applyTo: "frontend/**", source: "auto" }, + criteria: [ + { + id: "area-readme", + title: "Area README present", + pillar: "documentation", + level: 1, + scope: "area", + impact: "medium", + effort: "low", + status: "pass" + }, + { + id: "area-build-script", + title: "Area build script present", + pillar: "build-system", + level: 1, + scope: "area", + impact: "high", + effort: "low", + status: "fail", + reason: "Missing build script in area." + } + ], + pillars: [] + }, + { + area: { + name: "backend", + applyTo: "backend/**", + source: "config", + description: "API layer" + }, + criteria: [ + { + id: "area-readme", + title: "Area README present", + pillar: "documentation", + level: 1, + scope: "area", + impact: "medium", + effort: "low", + status: "pass" + } + ], + pillars: [] + } + ] + }); + + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report }] + }); + + expect(html).toContain("Per-Area Breakdown"); + expect(html).toContain("frontend"); + expect(html).toContain("backend"); + expect(html).toContain("frontend/**"); + expect(html).toContain("backend/**"); + expect(html).toContain("auto"); + expect(html).toContain("config"); + expect(html).toContain("Pass"); + expect(html).toContain("Fail"); + }); + + it("does not render area section when no areaReports", () => { + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report: makeReport() }] + }); + + expect(html).not.toContain("Per-Area Breakdown"); + }); + + it("escapes HTML in area names", () => { + const report = makeReport({ + areaReports: [ + { + area: { name: '', applyTo: "x/**", source: "auto" }, + criteria: [], + pillars: [] + } + ] + }); + + const html = generateVisualReport({ + reports: [{ repo: "test-repo", report }] + }); + + expect(html).not.toContain(''); + expect(html).toContain("<img"); + }); +}); diff --git a/src/services/analyzer.ts b/src/services/analyzer.ts index bd1b28a..f4dc214 100644 --- a/src/services/analyzer.ts +++ b/src/services/analyzer.ts @@ -1,13 +1,56 @@ import fs from "fs/promises"; import path from "path"; + +import fg from "fast-glob"; + +import { fileExists, safeReadDir, readJson } from "../utils/fs"; + import { isGitRepo } from "./git"; +export type RepoApp = { + name: string; + path: string; + ecosystem?: "node" | "rust" | "go" | "dotnet" | "java" | "python" | "ruby" | "php"; + manifestPath?: string; + packageJsonPath: string; + scripts: Record; + hasTsConfig: boolean; +}; + +export type Area = { + name: string; + description?: string; + applyTo: string | string[]; + path?: string; + ecosystem?: RepoApp["ecosystem"]; + source: "auto" | "config"; + scripts?: Record; + hasTsConfig?: boolean; +}; + export type RepoAnalysis = { path: string; isGitRepo: boolean; languages: string[]; frameworks: string[]; packageManager?: string; + isMonorepo?: boolean; + workspaceType?: + | "npm" + | "pnpm" + | "yarn" + | "cargo" + | "go" + | "dotnet" + | "gradle" + | "maven" + | "bazel" + | "nx" + | "pants" + | "turborepo"; + workspacePatterns?: string[]; + apps?: RepoApp[]; + areas?: Area[]; }; const PACKAGE_MANAGERS: Array<{ file: string; name: string }> = [ @@ -32,37 +75,102 @@ export async function analyzeRepo(repoPath: string): Promise { const hasRequirements = files.includes("requirements.txt"); const hasGoMod = files.includes("go.mod"); const hasCargo = files.includes("Cargo.toml"); + const hasCsproj = files.some((f) => f.endsWith(".csproj") || f.endsWith(".sln")); + const hasPomXml = files.includes("pom.xml"); + const hasBuildGradle = files.includes("build.gradle") || files.includes("build.gradle.kts"); + const hasGemfile = files.includes("Gemfile"); + const hasComposerJson = files.includes("composer.json"); + const hasCMakeLists = files.includes("CMakeLists.txt"); + const hasMakefile = files.includes("Makefile") || files.includes("GNUmakefile"); + const hasMesonBuild = files.includes("meson.build"); + const hasConfigure = files.includes("configure") || files.includes("configure.ac"); + const hasMozBuild = files.includes("moz.build"); if (hasPackageJson) analysis.languages.push("JavaScript"); if (hasTsConfig) analysis.languages.push("TypeScript"); if (hasPyProject || hasRequirements) analysis.languages.push("Python"); if (hasGoMod) analysis.languages.push("Go"); if (hasCargo) analysis.languages.push("Rust"); + if (hasCsproj) analysis.languages.push("C#"); + if (hasPomXml || hasBuildGradle) analysis.languages.push("Java"); + if (hasGemfile) analysis.languages.push("Ruby"); + if (hasComposerJson) analysis.languages.push("PHP"); + if (hasCMakeLists || hasMesonBuild || hasConfigure || hasMozBuild) analysis.languages.push("C++"); + if (hasMakefile && !analysis.languages.length) analysis.languages.push("C"); analysis.packageManager = await detectPackageManager(repoPath, files); + let rootPackageJson: Record | undefined; + if (hasPackageJson) { - const packageJson = await readJson(path.join(repoPath, "package.json")); + rootPackageJson = await readJson(path.join(repoPath, "package.json")); const deps = Object.keys({ - ...(packageJson?.dependencies ?? {}), - ...(packageJson?.devDependencies ?? {}) + ...(rootPackageJson?.dependencies ?? {}), + ...(rootPackageJson?.devDependencies ?? {}) }); analysis.frameworks.push(...detectFrameworks(deps, files)); } + const workspace = await detectWorkspace(repoPath, files, rootPackageJson); + if (workspace) { + analysis.workspaceType = workspace.type; + analysis.workspacePatterns = workspace.patterns; + } + + let apps = await resolveWorkspaceApps(repoPath, workspace?.patterns ?? [], rootPackageJson); + + // If JS workspace didn't find multiple apps, try non-JS monorepo detection + if (apps.length <= 1) { + const nonJs = await detectNonJsMonorepo(repoPath, files); + if (nonJs.apps.length > 1) { + apps = nonJs.apps; + if (nonJs.type) analysis.workspaceType = nonJs.type; + if (nonJs.patterns) analysis.workspacePatterns = nonJs.patterns; + } + } + + if (workspace && files.includes("turbo.json") && apps.length > 1) { + analysis.workspaceType = "turborepo"; + } + + if (files.includes("nx.json") && apps.length > 1 && analysis.workspaceType !== "turborepo") { + analysis.workspaceType = "nx"; + } + + analysis.apps = apps; + analysis.isMonorepo = apps.length > 1; + analysis.languages = unique(analysis.languages); analysis.frameworks = unique(analysis.frameworks); + // Detect areas from apps and folder heuristics + analysis.areas = await detectAreas(repoPath, analysis); + return analysis; } -async function detectPackageManager(repoPath: string, files: string[]): Promise { +async function detectPackageManager( + _repoPath: string, + files: string[] +): Promise { for (const manager of PACKAGE_MANAGERS) { if (files.includes(manager.file)) return manager.name; } if (files.includes("package.json")) return "npm"; if (files.includes("pyproject.toml")) return "pip"; + if (files.includes("pom.xml")) return "maven"; + if (files.includes("build.gradle") || files.includes("build.gradle.kts")) return "gradle"; + if (files.includes("Gemfile")) return "bundler"; + if (files.includes("composer.json")) return "composer"; + if ( + files.includes("MODULE.bazel") || + files.includes("WORKSPACE") || + files.includes("WORKSPACE.bazel") + ) + return "bazel"; + if (files.includes("pants.toml")) return "pants"; + if (files.includes("nx.json")) return "nx"; return undefined; } @@ -70,7 +178,8 @@ function detectFrameworks(deps: string[], files: string[]): string[] { const frameworks: string[] = []; const hasFile = (file: string): boolean => files.includes(file); - if (deps.includes("next") || hasFile("next.config.js") || hasFile("next.config.mjs")) frameworks.push("Next.js"); + if (deps.includes("next") || hasFile("next.config.js") || hasFile("next.config.mjs")) + frameworks.push("Next.js"); if (deps.includes("react") || deps.includes("react-dom")) frameworks.push("React"); if (deps.includes("vue") || hasFile("vue.config.js")) frameworks.push("Vue"); if (deps.includes("@angular/core") || hasFile("angular.json")) frameworks.push("Angular"); @@ -82,23 +191,865 @@ function detectFrameworks(deps: string[], files: string[]): string[] { return frameworks; } -async function safeReadDir(dirPath: string): Promise { +async function safeReadFile(filePath: string): Promise { try { - return await fs.readdir(dirPath); + return await fs.readFile(filePath, "utf8"); } catch { - return []; + return undefined; + } +} + +async function isScannableDirectory(repoPath: string, candidatePath: string): Promise { + try { + const stat = await fs.lstat(candidatePath); + if (stat.isSymbolicLink() || !stat.isDirectory()) return false; + + const resolvedRoot = await fs.realpath(repoPath).catch(() => path.resolve(repoPath)); + const resolvedCandidate = await fs + .realpath(candidatePath) + .catch(() => path.resolve(candidatePath)); + + return ( + resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + path.sep) + ); + } catch { + return false; } } -async function readJson(filePath: string): Promise | undefined> { +type WorkspaceConfig = { + type: "npm" | "pnpm" | "yarn"; + patterns: string[]; +}; + +async function detectWorkspace( + repoPath: string, + files: string[], + packageJson?: Record +): Promise { + if (files.includes("pnpm-workspace.yaml")) { + const patterns = await readPnpmWorkspace(path.join(repoPath, "pnpm-workspace.yaml")); + if (patterns.length) return { type: "pnpm", patterns }; + } + + const workspaces = packageJson?.workspaces; + if (Array.isArray(workspaces)) { + return { type: files.includes("yarn.lock") ? "yarn" : "npm", patterns: workspaces.map(String) }; + } + + if (workspaces && typeof workspaces === "object") { + const packages = (workspaces as { packages?: unknown }).packages; + if (Array.isArray(packages)) { + return { type: files.includes("yarn.lock") ? "yarn" : "npm", patterns: packages.map(String) }; + } + } + + return undefined; +} + +async function readPnpmWorkspace(filePath: string): Promise { try { const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw) as Record; + const lines = raw.split(/\r?\n/u); + const patterns: string[] = []; + let inPackages = false; + for (const line of lines) { + // Skip comment-only lines + if (/^\s*#/u.test(line)) continue; + if (!inPackages && /^\s*packages\s*:/u.test(line)) { + // Handle inline array: packages: ["apps/*", "libs/*"] + const inline = line.match(/packages\s*:\s*\[([^\]]+)\]/u); + if (inline) { + const items = inline[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/gu, "")); + return items.filter(Boolean); + } + inPackages = true; + continue; + } + if (inPackages) { + const match = line.match(/^\s*-\s*(.+)$/u); + if (match?.[1]) { + // Strip trailing comments and quotes + const value = match[1] + .split("#")[0] + .trim() + .replace(/^['"]|['"]$/gu, ""); + if (value) patterns.push(value); + continue; + } + // Non-indented, non-empty line means a new top-level key + if (/^\S/u.test(line) && line.trim()) break; + } + } + return patterns; } catch { - return undefined; + return []; + } +} + +async function resolveWorkspaceApps( + repoPath: string, + patterns: string[], + rootPackageJson?: Record +): Promise { + const workspacePatterns = patterns + .map((pattern) => pattern.replace(/\\/gu, "/")) + .map((pattern) => + pattern.endsWith("package.json") ? pattern : path.posix.join(pattern, "package.json") + ); + + const packageJsonPaths = workspacePatterns.length + ? ( + await fg(workspacePatterns, { cwd: repoPath, absolute: true, onlyFiles: true, dot: false }) + ).map((p) => path.normalize(p)) + : []; + + if (!packageJsonPaths.length && rootPackageJson) { + const rootPath = path.join(repoPath, "package.json"); + return [await buildRepoApp(repoPath, rootPath, rootPackageJson)]; + } + + const apps = await Promise.all( + packageJsonPaths.map(async (pkgPath) => { + const pkg = await readJson(pkgPath); + return buildRepoApp(path.dirname(pkgPath), pkgPath, pkg); + }) + ); + + return apps.filter(Boolean) as RepoApp[]; +} + +async function buildRepoApp( + appPath: string, + packageJsonPath: string, + packageJson?: Record +): Promise { + const scripts = (packageJson?.scripts ?? {}) as Record; + const name = typeof packageJson?.name === "string" ? packageJson.name : path.basename(appPath); + const hasTsConfig = await fileExists(path.join(appPath, "tsconfig.json")); + + return { + name, + path: appPath, + ecosystem: "node", + manifestPath: packageJsonPath, + packageJsonPath, + scripts, + hasTsConfig + }; +} + +function buildNonJsApp( + name: string, + appPath: string, + ecosystem: RepoApp["ecosystem"], + manifestPath: string +): RepoApp { + return { + name, + path: appPath, + ecosystem, + manifestPath, + packageJsonPath: "", + scripts: {}, + hasTsConfig: false + }; +} + +// ─── Non-JS monorepo detection ─── + +type NonJsMonorepoResult = { + type?: RepoAnalysis["workspaceType"]; + patterns?: string[]; + apps: RepoApp[]; +}; + +async function detectNonJsMonorepo( + repoPath: string, + files: string[] +): Promise { + const cargoApps = await detectCargoWorkspace(repoPath); + if (cargoApps.length > 1) return { type: "cargo", apps: cargoApps }; + + const goApps = await detectGoWorkspace(repoPath); + if (goApps.length > 1) return { type: "go", apps: goApps }; + + const dotnetApps = await detectDotnetSolution(repoPath, files); + if (dotnetApps.length > 1) return { type: "dotnet", apps: dotnetApps }; + + const gradleApps = await detectGradleMultiProject(repoPath, files); + if (gradleApps.length > 1) return { type: "gradle", apps: gradleApps }; + + const mavenApps = await detectMavenMultiModule(repoPath); + if (mavenApps.length > 1) return { type: "maven", apps: mavenApps }; + + const bazelApps = await detectBazelWorkspace(repoPath, files); + if (bazelApps.length > 1) return { type: "bazel", apps: bazelApps }; + + const nxApps = await detectNxWorkspace(repoPath, files); + if (nxApps.length > 1) return { type: "nx", apps: nxApps }; + + const pantsApps = await detectPantsWorkspace(repoPath, files); + if (pantsApps.length > 1) return { type: "pants", apps: pantsApps }; + + return { apps: [] }; +} + +async function detectCargoWorkspace(repoPath: string): Promise { + const content = await safeReadFile(path.join(repoPath, "Cargo.toml")); + if (!content) return []; + + // Extract [workspace] section up to the next section header + const workspaceSection = content.match(/\[workspace\]([\s\S]*?)(?:\n\[|$)/u); + if (!workspaceSection) return []; + + const membersMatch = workspaceSection[1].match(/members\s*=\s*\[([\s\S]*?)\]/u); + if (!membersMatch) return []; + + const patterns = [...membersMatch[1].matchAll(/"([^"]+)"/gu)].map((m) => m[1]); + if (!patterns.length) return []; + + const tomlPaths = ( + await fg( + patterns.map((p) => path.posix.join(p, "Cargo.toml")), + { cwd: repoPath, absolute: true, onlyFiles: true } + ) + ).map((p) => path.normalize(p)); + + return Promise.all( + tomlPaths.map(async (tomlPath) => { + const dir = path.dirname(tomlPath); + const toml = await safeReadFile(tomlPath); + const nameMatch = toml?.match(/^\s*name\s*=\s*"([^"]+)"/mu); + return buildNonJsApp(nameMatch?.[1] ?? path.basename(dir), dir, "rust", tomlPath); + }) + ); +} + +async function detectGoWorkspace(repoPath: string): Promise { + const content = await safeReadFile(path.join(repoPath, "go.work")); + if (!content) return []; + + const modules: string[] = []; + + // Block form: use ( ./cmd/server \n ./pkg/lib ) + const blockMatch = content.match(/use\s*\(([\s\S]*?)\)/u); + if (blockMatch) { + for (const line of blockMatch[1].split(/\r?\n/u)) { + const trimmed = line.replace(/\/\/.*$/u, "").trim(); + if (trimmed) modules.push(trimmed); + } } + + // Single-line form: use ./cmd/server + for (const match of content.matchAll(/^use\s+(\S+)\s*$/gmu)) { + modules.push(match[1]); + } + + const apps: RepoApp[] = []; + for (const mod of modules) { + const modPath = path.resolve(repoPath, mod); + const goModPath = path.join(modPath, "go.mod"); + if (!(await fileExists(goModPath))) continue; + + const goMod = await safeReadFile(goModPath); + const nameMatch = goMod?.match(/^module\s+(\S+)/mu); + const shortName = nameMatch?.[1]?.split("/").pop() ?? path.basename(modPath); + apps.push(buildNonJsApp(shortName, modPath, "go", goModPath)); + } + + return apps; +} + +async function detectDotnetSolution(repoPath: string, files: string[]): Promise { + const slnFile = files.find((f) => f.endsWith(".sln")); + if (!slnFile) return []; + + const content = await safeReadFile(path.join(repoPath, slnFile)); + if (!content) return []; + + // Match: Project("{guid}") = "Name", "path\to\Project.csproj", "{guid}" + const projectRegex = /Project\("[^"]*"\)\s*=\s*"([^"]+)",\s*"([^"]+\.(?:cs|fs|vb)proj)"/giu; + const apps: RepoApp[] = []; + + for (const match of content.matchAll(projectRegex)) { + const name = match[1]; + const projRelPath = match[2].replace(/\\/gu, "/"); + const projPath = path.resolve(repoPath, projRelPath); + const appDir = path.dirname(projPath); + + if (await fileExists(projPath)) { + apps.push(buildNonJsApp(name, appDir, "dotnet", projPath)); + } + } + + return apps; +} + +async function detectGradleMultiProject(repoPath: string, files: string[]): Promise { + const settingsFile = files.includes("settings.gradle.kts") + ? "settings.gradle.kts" + : files.includes("settings.gradle") + ? "settings.gradle" + : null; + if (!settingsFile) return []; + + const content = await safeReadFile(path.join(repoPath, settingsFile)); + if (!content) return []; + + // Extract all Gradle project references (':app', ':lib:core') from the file + const projectNames: string[] = []; + for (const match of content.matchAll(/['"](:(?:[\w.-]+:)*[\w.-]+)['"]/gu)) { + projectNames.push(match[1].replace(/^:/u, "").replace(/:/gu, "/")); + } + + const uniqueProjects = [...new Set(projectNames)]; + const apps: RepoApp[] = []; + + for (const project of uniqueProjects) { + const projectDir = path.resolve(repoPath, project); + const ktsPath = path.join(projectDir, "build.gradle.kts"); + const groovyPath = path.join(projectDir, "build.gradle"); + + const buildFile = (await fileExists(ktsPath)) + ? ktsPath + : (await fileExists(groovyPath)) + ? groovyPath + : null; + + if (buildFile) { + apps.push(buildNonJsApp(path.basename(project), projectDir, "java", buildFile)); + } + } + + return apps; +} + +async function detectMavenMultiModule(repoPath: string): Promise { + const content = await safeReadFile(path.join(repoPath, "pom.xml")); + if (!content) return []; + + const apps: RepoApp[] = []; + for (const match of content.matchAll(/([^<]+)<\/module>/gu)) { + const modName = match[1].trim(); + const modDir = path.resolve(repoPath, modName); + const pomPath = path.join(modDir, "pom.xml"); + + if (await fileExists(pomPath)) { + apps.push(buildNonJsApp(path.basename(modName), modDir, "java", pomPath)); + } + } + + return apps; +} + +async function detectBazelWorkspace(repoPath: string, files: string[]): Promise { + const hasBazel = + files.includes("MODULE.bazel") || + files.includes("WORKSPACE") || + files.includes("WORKSPACE.bazel"); + if (!hasBazel) return []; + + // Scan first-level directories for BUILD / BUILD.bazel files + const entries = await safeReadDir(repoPath); + const apps: RepoApp[] = []; + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + const children = await safeReadDir(fullPath); + const buildFile = children.includes("BUILD") + ? "BUILD" + : children.includes("BUILD.bazel") + ? "BUILD.bazel" + : undefined; + if (!buildFile) continue; + + apps.push(buildNonJsApp(entry, fullPath, undefined, path.join(fullPath, buildFile))); + } + + return apps; +} + +async function detectNxWorkspace(repoPath: string, files: string[]): Promise { + if (!files.includes("nx.json")) return []; + + // Find project.json files (depth-limited via glob pattern) + const projectJsonPaths = ( + await fg(["*/project.json", "*/*/project.json", "*/*/*/project.json"], { + cwd: repoPath, + absolute: true, + onlyFiles: true, + dot: false, + followSymbolicLinks: false, + ignore: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/build/**", "**/out/**"] + }) + ).map((p) => path.normalize(p)); + + const apps: RepoApp[] = []; + for (const projPath of projectJsonPaths) { + const projDir = path.dirname(projPath); + const projJson = await readJson(projPath); + const name = typeof projJson?.name === "string" ? projJson.name : path.basename(projDir); + // Detect ecosystem from sibling files + const children = await safeReadDir(projDir); + const ecosystem: RepoApp["ecosystem"] = children.includes("package.json") + ? "node" + : children.includes("Cargo.toml") + ? "rust" + : children.includes("go.mod") + ? "go" + : children.includes("pyproject.toml") + ? "python" + : undefined; + apps.push({ + name, + path: projDir, + ecosystem, + manifestPath: projPath, + packageJsonPath: children.includes("package.json") ? path.join(projDir, "package.json") : "", + scripts: {}, + hasTsConfig: children.includes("tsconfig.json") + }); + } + + return apps; +} + +async function detectPantsWorkspace(repoPath: string, files: string[]): Promise { + if (!files.includes("pants.toml")) return []; + + // Scan first-level directories for BUILD files + const entries = await safeReadDir(repoPath); + const apps: RepoApp[] = []; + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + const children = await safeReadDir(fullPath); + const buildFile = children.includes("BUILD") + ? "BUILD" + : children.includes("BUILD.pants") + ? "BUILD.pants" + : undefined; + if (!buildFile) continue; + + // Infer ecosystem from sibling files + const ecosystem: RepoApp["ecosystem"] = children.includes("pyproject.toml") + ? "python" + : children.includes("go.mod") + ? "go" + : children.includes("Cargo.toml") + ? "rust" + : undefined; + apps.push(buildNonJsApp(entry, fullPath, ecosystem, path.join(fullPath, buildFile))); + } + + return apps; } function unique(items: T[]): T[] { return Array.from(new Set(items)); } + +// ─── Area detection ─── + +const AREA_HEURISTIC_DIRS = [ + "frontend", + "backend", + "api", + "web", + "mobile", + "app", + "server", + "client", + "infra", + "infrastructure", + "shared", + "common", + "lib", + "libs", + "packages", + "services", + "docs", + "scripts", + "tools", + "cli", + "sdk", + "core", + "admin", + "portal", + "dashboard", + "worker", + "functions", + // Browser / engine components + "browser", + "devtools", + "toolkit", + "dom", + "layout", + "media", + "security", + "testing", + "extensions", + "modules", + "editor", + "remote", + "storage" +]; + +// Directories to skip in fallback area detection +const FALLBACK_SKIP_DIRS = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "target", + "build", + "dist", + "out", + "output", + ".output", + ".next", + "vendor", + "third_party", + "other-licenses", + "coverage", + "__pycache__", + ".cache", + ".vscode", + ".idea", + ".github", + ".gitlab", + ".circleci", + "supply-chain", + "gradle", + ".cargo" +]); + +const MIN_FALLBACK_CHILDREN = 3; +const MIN_AREAS_FOR_FALLBACK = 3; +const MIN_TOPLEVEL_DIRS_FOR_FALLBACK = 10; + +const MANIFEST_FILES = [ + "package.json", + "pyproject.toml", + "requirements.txt", + "go.mod", + "Cargo.toml", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "Gemfile", + "composer.json", + "setup.py", + "setup.cfg", + "CMakeLists.txt", + "meson.build", + "BUILD", + "BUILD.bazel", + "moz.build" +]; + +const CODE_EXTENSIONS = [ + ".ts", + ".js", + ".py", + ".go", + ".rs", + ".java", + ".cs", + ".rb", + ".php", + ".c", + ".cc", + ".cpp", + ".h", + ".hpp", + ".swift", + ".kt", + ".scala" +]; + +function areasFromApps(repoPath: string, apps: RepoApp[]): Area[] { + return apps.map((app) => { + const rel = path.relative(repoPath, app.path).replace(/\\/gu, "/"); + return { + name: app.name, + applyTo: `${rel}/**`, + path: app.path, + ecosystem: app.ecosystem, + source: "auto" as const, + scripts: Object.keys(app.scripts).length > 0 ? app.scripts : undefined, + hasTsConfig: app.hasTsConfig || undefined + }; + }); +} + +async function areasFromHeuristics(repoPath: string): Promise { + const entries = await safeReadDir(repoPath); + const areas: Area[] = []; + + for (const entry of entries) { + const lower = entry.toLowerCase(); + if (!AREA_HEURISTIC_DIRS.includes(lower)) continue; + + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + + // Check if the directory has meaningful content (manifest or code files) + const children = await safeReadDir(fullPath); + const hasManifest = children.some((c) => MANIFEST_FILES.includes(c)); + const hasCode = children.some((c) => CODE_EXTENSIONS.some((ext) => c.endsWith(ext))); + const hasSrcDir = children.includes("src"); + + if (!hasManifest && !hasCode && !hasSrcDir) continue; + + // Read scripts from manifest if present + let scripts: Record | undefined; + let hasTsConfig: boolean | undefined; + if (children.includes("package.json")) { + const pkg = await readJson(path.join(fullPath, "package.json")); + const pkgScripts = (pkg?.scripts ?? {}) as Record; + if (Object.keys(pkgScripts).length > 0) scripts = pkgScripts; + } + if (children.includes("tsconfig.json")) { + hasTsConfig = true; + } + + areas.push({ + name: entry, + applyTo: `${entry}/**`, + path: fullPath, + source: "auto", + scripts, + hasTsConfig + }); + } + + return areas; +} + +/** + * Fallback area detection for large repos (e.g., Firefox, Chromium) where + * neither workspace managers nor the heuristic directory list provide good coverage. + * Scans first-level directories that contain code or manifests and meet a + * small minimum-size threshold to reduce noise. + */ +async function areasFromFallback(repoPath: string, existingAreas: Area[]): Promise { + const existingNames = new Set(existingAreas.map((a) => a.name.toLowerCase())); + const entries = await safeReadDir(repoPath); + const areas: Area[] = []; + + for (const entry of entries) { + if (entry.startsWith(".")) continue; + if (FALLBACK_SKIP_DIRS.has(entry.toLowerCase())) continue; + if (existingNames.has(entry.toLowerCase())) continue; + + const fullPath = path.join(repoPath, entry); + if (!(await isScannableDirectory(repoPath, fullPath))) continue; + + const children = await safeReadDir(fullPath); + if (children.length < MIN_FALLBACK_CHILDREN) continue; + + const hasManifest = children.some((c) => MANIFEST_FILES.includes(c)); + const hasCode = children.some((c) => CODE_EXTENSIONS.some((ext) => c.endsWith(ext))); + const hasSrcDir = children.includes("src"); + + if (!hasManifest && !hasCode && !hasSrcDir) continue; + + areas.push({ + name: entry, + applyTo: `${entry}/**`, + path: fullPath, + source: "auto" + }); + } + + return areas; +} + +async function detectAreas(repoPath: string, analysis: RepoAnalysis): Promise { + let autoAreas: Area[]; + + if (analysis.isMonorepo && analysis.apps && analysis.apps.length > 1) { + const appAreas = areasFromApps(repoPath, analysis.apps); + // Also run heuristics to catch non-app directories (docs, infra, etc.) + const heuristicAreas = await areasFromHeuristics(repoPath); + // Merge: app areas take precedence by name + const byName = new Map(heuristicAreas.map((a) => [a.name.toLowerCase(), a])); + for (const a of appAreas) { + byName.set(a.name.toLowerCase(), a); + } + autoAreas = Array.from(byName.values()); + } else { + autoAreas = await areasFromHeuristics(repoPath); + } + + // Smart fallback: if few areas detected but repo has many top-level dirs, + // scan all first-level directories for code content + const topLevelEntries = await safeReadDir(repoPath); + const topLevelDirCount = ( + await Promise.all( + topLevelEntries + .filter((e) => !e.startsWith(".")) + .map(async (e) => isScannableDirectory(repoPath, path.join(repoPath, e))) + ) + ).filter(Boolean).length; + + if ( + autoAreas.length < MIN_AREAS_FOR_FALLBACK && + topLevelDirCount > MIN_TOPLEVEL_DIRS_FOR_FALLBACK + ) { + const fallbackAreas = await areasFromFallback(repoPath, autoAreas); + const byName = new Map(autoAreas.map((a) => [a.name.toLowerCase(), a])); + for (const a of fallbackAreas) { + if (!byName.has(a.name.toLowerCase())) { + byName.set(a.name.toLowerCase(), a); + } + } + autoAreas = Array.from(byName.values()); + } + + // Merge with config areas + const config = await loadPrimerConfig(repoPath); + if (!config?.areas?.length) return autoAreas; + + const resolvedRoot = path.resolve(repoPath); + const configAreas: Area[] = []; + for (const ca of config.areas) { + // Derive path: extract leading directory from first applyTo pattern, ignoring glob-only patterns + const patterns = Array.isArray(ca.applyTo) ? ca.applyTo : [ca.applyTo]; + const firstSegment = patterns[0].split("/")[0]; + const basePath = + firstSegment.includes("*") || firstSegment.includes("?") + ? repoPath + : path.join(repoPath, firstSegment); + + // Prevent path traversal — config areas must stay inside the repo + const resolved = path.resolve(basePath); + if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) continue; + + // Enrich config areas with scripts/hasTsConfig + let scripts: Record | undefined; + let hasTsConfig: boolean | undefined; + try { + const children = await safeReadDir(basePath); + if (children.includes("package.json")) { + const pkg = await readJson(path.join(basePath, "package.json")); + const pkgScripts = (pkg?.scripts ?? {}) as Record; + if (Object.keys(pkgScripts).length > 0) scripts = pkgScripts; + } + if (children.includes("tsconfig.json")) hasTsConfig = true; + } catch { + // Directory may not exist yet for config areas + } + + configAreas.push({ + name: ca.name, + description: ca.description, + applyTo: ca.applyTo, + path: basePath, + source: "config" as const, + scripts, + hasTsConfig + }); + } + + // Config areas override auto-detected by name (case-insensitive) + const autoByName = new Map(autoAreas.map((a) => [a.name.toLowerCase(), a])); + for (const ca of configAreas) { + autoByName.set(ca.name.toLowerCase(), ca); + } + + return Array.from(autoByName.values()); +} + +// ─── Primer config ─── + +export type PrimerConfigArea = { + name: string; + applyTo: string | string[]; + description?: string; +}; + +export type PrimerConfig = { + areas?: PrimerConfigArea[]; + policies?: string[]; +}; + +export async function loadPrimerConfig(repoPath: string): Promise { + // Try repo root first, then .github/ + const candidates = [ + path.join(repoPath, "primer.config.json"), + path.join(repoPath, ".github", "primer.config.json") + ]; + + for (const candidate of candidates) { + const json = await readJson(candidate); + if (!json) continue; + + // Validate shape + if (json.areas !== undefined && !Array.isArray(json.areas)) { + return undefined; + } + const areas: PrimerConfigArea[] = []; + if (Array.isArray(json.areas)) { + for (const entry of json.areas) { + if ( + typeof entry === "object" && + entry !== null && + typeof (entry as Record).name === "string" && + (entry as Record).applyTo !== undefined + ) { + const e = entry as Record; + if (!(e.name as string).trim()) continue; + const rawApplyTo = e.applyTo; + // Validate applyTo is a string or array of strings + let applyTo: string | string[]; + if (typeof rawApplyTo === "string") { + applyTo = rawApplyTo; + } else if (Array.isArray(rawApplyTo) && rawApplyTo.every((v) => typeof v === "string")) { + applyTo = rawApplyTo as string[]; + } else { + continue; + } + if ( + (typeof applyTo === "string" && !applyTo.trim()) || + (Array.isArray(applyTo) && applyTo.length === 0) + ) + continue; + // Reject patterns with path traversal segments + const allPatterns = Array.isArray(applyTo) ? applyTo : [applyTo]; + if (allPatterns.some((p) => p.split("/").includes(".."))) continue; + areas.push({ + name: e.name as string, + applyTo, + description: typeof e.description === "string" ? e.description : undefined + }); + } + } + } + + // Parse policies array + let policies: string[] | undefined; + if (Array.isArray(json.policies)) { + policies = json.policies.filter((p): p is string => typeof p === "string" && p.trim() !== ""); + } + + return { areas, policies: policies?.length ? policies : undefined }; + } + + return undefined; +} + +export function sanitizeAreaName(name: string): string { + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9-]/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-|-$/gu, ""); + return sanitized || "unnamed"; +} diff --git a/src/services/azureDevops.ts b/src/services/azureDevops.ts new file mode 100644 index 0000000..bf5c8e1 --- /dev/null +++ b/src/services/azureDevops.ts @@ -0,0 +1,287 @@ +type AzureDevOpsProfileResponse = { + id: string; + displayName: string; +}; + +type AzureDevOpsAccountResponse = { + accountId: string; + accountName: string; + accountUri: string; +}; + +type AzureDevOpsListResponse = { + value: T[]; +}; + +type AzureDevOpsProjectResponse = { + id: string; + name: string; + url: string; +}; + +type AzureDevOpsRepoResponse = { + id: string; + name: string; + webUrl: string; + remoteUrl: string; + isPrivate: boolean; + defaultBranch?: string; + project?: { + id: string; + name: string; + }; +}; + +export type AzureDevOpsOrg = { + id: string; + name: string; + url: string; +}; + +export type AzureDevOpsProject = { + id: string; + name: string; + organization: string; + url: string; +}; + +export type AzureDevOpsRepo = { + id: string; + name: string; + organization: string; + project: string; + projectId: string; + webUrl: string; + cloneUrl: string; + isPrivate: boolean; + defaultBranch: string; + hasInstructions?: boolean; +}; + +const PROFILE_URL = + "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1-preview.1"; + +const ADO_SLUG_RE = /^[\w][\w.-]*$/u; + +function validateAdoSlug(value: string, label: string): string { + if (!ADO_SLUG_RE.test(value)) { + throw new Error(`Invalid ${label}: ${value}`); + } + return encodeURIComponent(value); +} + +export type AdoAuthMode = "pat" | "bearer"; + +function getAuthHeader(token: string, authMode: AdoAuthMode = "pat"): string { + if (authMode === "bearer") { + return `Bearer ${token}`; + } + const encoded = Buffer.from(`:${token}`).toString("base64"); + return `Basic ${encoded}`; +} + +async function adoRequest( + url: string, + token: string, + init?: RequestInit & { authMode?: AdoAuthMode } +): Promise { + const { authMode, ...fetchInit } = init ?? {}; + const response = await fetch(url, { + ...fetchInit, + headers: { + "Content-Type": "application/json", + Authorization: getAuthHeader(token, authMode), + ...(fetchInit.headers ?? {}) + } + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Azure DevOps request failed (${response.status}): ${text}`); + } + + return (await response.json()) as T; +} + +export function getAzureDevOpsToken(): string | null { + return process.env.AZURE_DEVOPS_PAT ?? process.env.AZDO_PAT ?? null; +} + +export async function listOrganizations(token: string): Promise { + const profile = await adoRequest(PROFILE_URL, token); + const accountsUrl = `https://app.vssps.visualstudio.com/_apis/accounts?memberId=${encodeURIComponent(profile.id)}&api-version=7.1-preview.1`; + const accounts = await adoRequest>( + accountsUrl, + token + ); + + return accounts.value.map((account) => ({ + id: account.accountId, + name: account.accountName, + url: account.accountUri + })); +} + +export async function listProjects( + token: string, + organization: string +): Promise { + const org = validateAdoSlug(organization, "organization"); + const url = `https://dev.azure.com/${org}/_apis/projects?stateFilter=wellFormed&api-version=7.1-preview.1`; + const response = await adoRequest>( + url, + token + ); + + return response.value.map((project) => ({ + id: project.id, + name: project.name, + organization, + url: project.url + })); +} + +export async function listRepos( + token: string, + organization: string, + project: string +): Promise { + const org = validateAdoSlug(organization, "organization"); + const proj = validateAdoSlug(project, "project"); + const url = `https://dev.azure.com/${org}/${proj}/_apis/git/repositories?api-version=7.1-preview.1`; + const response = await adoRequest>(url, token); + + return response.value.map((repo) => ({ + id: repo.id, + name: repo.name, + organization, + project, + projectId: repo.project?.id ?? "", + webUrl: repo.webUrl, + cloneUrl: repo.remoteUrl, + isPrivate: repo.isPrivate, + defaultBranch: repo.defaultBranch ?? "refs/heads/main" + })); +} + +export async function getRepo( + token: string, + organization: string, + project: string, + repo: string, + authMode: AdoAuthMode = "pat" +): Promise { + const org = validateAdoSlug(organization, "organization"); + const proj = validateAdoSlug(project, "project"); + const r = validateAdoSlug(repo, "repo"); + const url = `https://dev.azure.com/${org}/${proj}/_apis/git/repositories/${r}?api-version=7.1-preview.1`; + const response = await adoRequest(url, token, { authMode }); + + return { + id: response.id, + name: response.name, + organization, + project, + projectId: response.project?.id ?? "", + webUrl: response.webUrl, + cloneUrl: response.remoteUrl, + isPrivate: response.isPrivate, + defaultBranch: response.defaultBranch ?? "refs/heads/main" + }; +} + +function toRefName(branch: string): string { + if (branch.startsWith("refs/")) return branch; + return `refs/heads/${branch}`; +} + +export async function createPullRequest(params: { + token: string; + organization: string; + project: string; + repoId: string; + repoName: string; + title: string; + body: string; + sourceBranch: string; + targetBranch: string; + authMode?: AdoAuthMode; +}): Promise { + const org = validateAdoSlug(params.organization, "organization"); + const proj = validateAdoSlug(params.project, "project"); + const url = `https://dev.azure.com/${org}/${proj}/_apis/git/repositories/${encodeURIComponent(params.repoId)}/pullrequests?api-version=7.1-preview.1`; + const payload = { + title: params.title, + description: params.body, + sourceRefName: toRefName(params.sourceBranch), + targetRefName: toRefName(params.targetBranch) + }; + + const response = await adoRequest<{ pullRequestId: number }>(url, params.token, { + method: "POST", + body: JSON.stringify(payload), + authMode: params.authMode + }); + + return `https://dev.azure.com/${org}/${proj}/_git/${encodeURIComponent( + params.repoName + )}/pullrequest/${response.pullRequestId}`; +} + +export async function checkRepoHasInstructions( + token: string, + organization: string, + project: string, + repoId: string, + authMode: AdoAuthMode = "pat" +): Promise { + const org = validateAdoSlug(organization, "organization"); + const proj = validateAdoSlug(project, "project"); + const url = `https://dev.azure.com/${org}/${proj}/_apis/git/repositories/${encodeURIComponent(repoId)}/items?path=/.github/copilot-instructions.md&includeContentMetadata=true&api-version=7.1-preview.1`; + const response = await fetch(url, { + headers: { + Authorization: getAuthHeader(token, authMode) + } + }); + + if (response.status === 404) { + return false; + } + + if (!response.ok) { + throw new Error(`Azure DevOps request failed (${response.status})`); + } + + return true; +} + +/** CLI-only batch helper — always uses PAT auth (no authMode param needed). */ +export async function checkReposForInstructions( + token: string, + repos: AzureDevOpsRepo[], + onProgress?: (checked: number, total: number) => void +): Promise { + const concurrency = 10; + const results: AzureDevOpsRepo[] = []; + let checked = 0; + + for (let i = 0; i < repos.length; i += concurrency) { + const batch = repos.slice(i, i + concurrency); + const checks = await Promise.all( + batch.map(async (repo) => { + const hasInstructions = await checkRepoHasInstructions( + token, + repo.organization, + repo.project, + repo.id + ); + return { ...repo, hasInstructions }; + }) + ); + results.push(...checks); + checked += batch.length; + onProgress?.(checked, repos.length); + } + + return results; +} diff --git a/src/services/batch.ts b/src/services/batch.ts new file mode 100644 index 0000000..dc118e2 --- /dev/null +++ b/src/services/batch.ts @@ -0,0 +1,281 @@ +import path from "path"; + +import { DEFAULT_MODEL } from "../config"; +import { ensureDir, safeWriteFile, validateCachePath } from "../utils/fs"; +import type { ProgressReporter } from "../utils/output"; +import { buildInstructionsPrBody } from "../utils/pr"; + +import type { AzureDevOpsRepo } from "./azureDevops"; +import { createPullRequest as createAzurePullRequest } from "./azureDevops"; +import { + buildAuthedUrl, + checkoutBranch, + cloneRepo, + commitAll, + isGitRepo, + pushBranch, + setRemoteUrl +} from "./git"; +import type { GitHubRepo } from "./github"; +import { createPullRequest as createGitHubPullRequest } from "./github"; +import { generateCopilotInstructions } from "./instructions"; + +// ── Types ── + +export type ProcessResult = { + repo: string; + success: boolean; + prUrl?: string; + error?: string; +}; + +export type ProcessGitHubRepoOptions = { + repo: GitHubRepo; + token: string; + branch?: string; + model?: string; + timeoutMs?: number; + progress?: ProgressReporter; +}; + +export type ProcessAzureRepoOptions = { + repo: AzureDevOpsRepo; + token: string; + branch?: string; + model?: string; + timeoutMs?: number; + progress?: ProgressReporter; +}; + +// ── Token sanitization ── + +export function sanitizeError(raw: string): string { + return raw + .replace(/x-access-token:[^@]+@/g, "x-access-token:***@") + .replace(/pat:[^@]+@/g, "pat:***@") + .replace(/https:\/\/[^@]+@/g, "https://***@"); +} + +// ── Shared repo processing core ── + +type ProcessRepoParams = { + label: string; + cacheParts: string[]; + cloneUrl: string; + token: string; + provider: "github" | "azure"; + branch: string; + model: string; + timeoutMs: number; + progress?: ProgressReporter; + createPr: (repoPath: string, branch: string) => Promise; +}; + +async function processRepo(params: ProcessRepoParams): Promise { + const { + label, + cacheParts, + cloneUrl, + token, + provider, + branch, + model, + timeoutMs, + progress, + createPr + } = params; + + try { + progress?.update(`${label}: Cloning...`); + const cacheRoot = path.join(process.cwd(), ".primer-cache"); + const repoPath = validateCachePath(cacheRoot, ...cacheParts); + await ensureDir(repoPath); + + if (!(await isGitRepo(repoPath))) { + const authedUrl = buildAuthedUrl(cloneUrl, token, provider); + try { + await cloneRepo(authedUrl, repoPath, { shallow: true, timeoutMs }); + } finally { + await setRemoteUrl(repoPath, cloneUrl).catch(() => {}); + } + } + + progress?.update(`${label}: Creating branch...`); + await checkoutBranch(repoPath, branch); + + progress?.update(`${label}: Generating instructions...`); + const instructions = await withTimeout( + generateCopilotInstructions({ + repoPath, + model, + onProgress: (msg) => progress?.update(`${label}: ${msg}`) + }), + timeoutMs + ); + + if (!instructions.trim()) { + throw new Error("Generated instructions were empty"); + } + + const instructionsPath = path.join(repoPath, ".github", "copilot-instructions.md"); + await ensureDir(path.dirname(instructionsPath)); + const { wrote, reason } = await safeWriteFile(instructionsPath, instructions, true); + if (!wrote) { + throw new Error( + `Refused to write instructions (${reason === "symlink" ? "path is a symlink" : "file exists"})` + ); + } + + progress?.update(`${label}: Committing...`); + await commitAll(repoPath, "chore: add copilot instructions via Primer"); + + progress?.update(`${label}: Pushing...`); + await pushBranch(repoPath, branch, token, provider); + + progress?.update(`${label}: Creating PR...`); + const prUrl = await createPr(repoPath, branch); + + progress?.succeed(`${label}: PR created`); + return { repo: label, success: true, prUrl }; + } catch (error) { + const msg = sanitizeError(error instanceof Error ? error.message : String(error)); + progress?.fail(`${label}: ${msg}`); + return { repo: label, success: false, error: msg }; + } +} + +// ── GitHub batch processing ── + +export async function processGitHubRepo(options: ProcessGitHubRepoOptions): Promise { + const { + repo, + token, + branch = "primer/add-instructions", + model = DEFAULT_MODEL, + timeoutMs = 120_000, + progress + } = options; + + return processRepo({ + label: repo.fullName, + cacheParts: [repo.owner, repo.name], + cloneUrl: repo.cloneUrl, + token, + provider: "github", + branch, + model, + timeoutMs, + progress, + createPr: (_repoPath, branchName) => + createGitHubPullRequest({ + token, + owner: repo.owner, + repo: repo.name, + title: "🤖 Add Copilot instructions via Primer", + body: buildInstructionsPrBody(), + head: branchName, + base: repo.defaultBranch + }) + }); +} + +// ── Azure DevOps batch processing ── + +export async function processAzureRepo(options: ProcessAzureRepoOptions): Promise { + const { + repo, + token, + branch = "primer/add-instructions", + model = DEFAULT_MODEL, + timeoutMs = 120_000, + progress + } = options; + + return processRepo({ + label: `${repo.organization}/${repo.project}/${repo.name}`, + cacheParts: [repo.organization, repo.project, repo.name], + cloneUrl: repo.cloneUrl, + token, + provider: "azure", + branch, + model, + timeoutMs, + progress, + createPr: (_repoPath, branchName) => + createAzurePullRequest({ + token, + organization: repo.organization, + project: repo.project, + repoId: repo.id, + repoName: repo.name, + title: "🤖 Add Copilot instructions via Primer", + body: buildInstructionsPrBody(), + sourceBranch: branchName, + targetBranch: repo.defaultBranch + }) + }); +} + +// ── Headless batch runners ── + +export async function runBatchHeadlessGitHub( + repos: GitHubRepo[], + token: string, + progress?: ProgressReporter, + options?: { model?: string; branch?: string } +): Promise { + const results: ProcessResult[] = []; + for (let i = 0; i < repos.length; i++) { + progress?.update(`[${i + 1}/${repos.length}] Processing ${repos[i].fullName}...`); + const result = await processGitHubRepo({ + repo: repos[i], + token, + progress, + model: options?.model, + branch: options?.branch + }); + results.push(result); + } + progress?.done(); + return results; +} + +export async function runBatchHeadlessAzure( + repos: AzureDevOpsRepo[], + token: string, + progress?: ProgressReporter, + options?: { model?: string; branch?: string } +): Promise { + const results: ProcessResult[] = []; + for (let i = 0; i < repos.length; i++) { + const label = `${repos[i].organization}/${repos[i].project}/${repos[i].name}`; + progress?.update(`[${i + 1}/${repos.length}] Processing ${label}...`); + const result = await processAzureRepo({ + repo: repos[i], + token, + progress, + model: options?.model, + branch: options?.branch + }); + results.push(result); + } + progress?.done(); + return results; +} + +// ── Helpers ── + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Operation timed out after ${timeoutMs / 1000}s`)), + timeoutMs + ); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer!); + } +} diff --git a/src/services/copilot.ts b/src/services/copilot.ts new file mode 100644 index 0000000..c666a0e --- /dev/null +++ b/src/services/copilot.ts @@ -0,0 +1,285 @@ +import fs from "fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "path"; + +import fg from "fast-glob"; + +const execFileAsync = promisify(execFile); + +const COPILOT_DEBUG_ENABLED = /^(1|true|yes|on)$/iu.test(process.env.PRIMER_DEBUG_COPILOT ?? ""); + +export function logCopilotDebug(message: string): void { + if (!COPILOT_DEBUG_ENABLED) return; + process.stderr.write(`[primer:copilot] ${message}\n`); +} + +export type CopilotCliConfig = { + cliPath: string; + cliArgs?: string[]; +}; + +type CopilotCliCandidate = { + config: CopilotCliConfig; + source: string; +}; + +let cachedCliConfig: CopilotCliConfig | null = null; +let cachedCliConfigTimestamp = 0; +const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +function cacheConfig(config: CopilotCliConfig): CopilotCliConfig { + cachedCliConfig = config; + cachedCliConfigTimestamp = Date.now(); + return config; +} + +export async function assertCopilotCliReady(): Promise { + const config = await findCopilotCliConfig(); + const desc = config.cliArgs ? `${config.cliPath} ${config.cliArgs.join(" ")}` : config.cliPath; + logCopilotDebug(`validating CLI compatibility with ${desc}`); + + try { + const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]); + await execFileAsync(cmd, args, { timeout: 5000 }); + } catch { + cachedCliConfig = null; + throw new Error( + `Copilot CLI at ${desc} is not compatible with SDK server mode. ` + + "Expected support for '--headless'. Install/update the VS Code Copilot Chat CLI or adjust PATH." + ); + } + + return config; +} + +export async function listCopilotModels(): Promise { + const config = await assertCopilotCliReady(); + const [cmd, args] = buildExecArgs(config, ["--help"]); + const { stdout } = await execFileAsync(cmd, args, { timeout: 5000 }); + return extractModelChoices(stdout); +} + +export function buildExecArgs(config: CopilotCliConfig, extraArgs: string[]): [string, string[]] { + if (config.cliArgs && config.cliArgs.length > 0) { + return [config.cliPath, [...config.cliArgs, ...extraArgs]]; + } + if ( + process.platform === "win32" && + (config.cliPath.endsWith(".bat") || config.cliPath.endsWith(".cmd")) + ) { + return ["cmd", ["/c", config.cliPath, ...extraArgs]]; + } + return [config.cliPath, extraArgs]; +} + +async function findCopilotCliConfig(): Promise { + if (cachedCliConfig && Date.now() - cachedCliConfigTimestamp < CLI_CACHE_TTL_MS) { + logCopilotDebug("using cached CLI config"); + return cachedCliConfig; + } + + const overrideCliPath = process.env.PRIMER_COPILOT_CLI_PATH; + if (overrideCliPath) { + const overrideConfig = { cliPath: overrideCliPath }; + logCopilotDebug(`trying override PRIMER_COPILOT_CLI_PATH=${overrideCliPath}`); + if (await isHeadlessCompatible(overrideConfig)) { + logCopilotDebug("override CLI is compatible"); + return cacheConfig(overrideConfig); + } + throw new Error( + `PRIMER_COPILOT_CLI_PATH points to an incompatible CLI (${overrideCliPath}). ` + + "It must support '--headless --version'." + ); + } + + const isWindows = process.platform === "win32"; + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + const appData = process.env.APPDATA ?? ""; + const candidates: CopilotCliCandidate[] = []; + + // On Windows, prefer npm-installed binary and use node + cliArgs approach. + // This bypasses .cmd/.bat wrapper issues that prevent direct spawning. + // See: https://github.com/microsoft/vscode/issues/291990 + if (isWindows && appData) { + const npmLoaderPath = path.join( + appData, + "npm", + "node_modules", + "@github", + "copilot", + "npm-loader.js" + ); + try { + await fs.access(npmLoaderPath); + candidates.push({ + config: { cliPath: process.execPath, cliArgs: [npmLoaderPath] }, + source: "Windows npm loader" + }); + logCopilotDebug( + `discovered candidate from Windows npm loader: ${process.execPath} ${npmLoaderPath}` + ); + } catch { + // npm binary not found, will try PATH and VS Code locations + } + } + + const whichCmd = isWindows ? "where" : "which"; + try { + const { stdout } = await execFileAsync(whichCmd, ["copilot"], { timeout: 5000 }); + const found = stdout.trim().split(/\r?\n/)[0]; + if (found) { + candidates.push({ config: { cliPath: found }, source: "PATH" }); + logCopilotDebug(`discovered candidate from PATH: ${found}`); + } + } catch { + // Not on PATH, will try VS Code locations + } + + const staticLocations: string[] = []; + + if (process.platform === "darwin") { + staticLocations.push( + `${home}/Library/Application Support/Code - Insiders/User/globalStorage/github.copilot-chat/copilotCli/copilot`, + `${home}/Library/Application Support/Code/User/globalStorage/github.copilot-chat/copilotCli/copilot` + ); + } else if (process.platform === "linux") { + staticLocations.push( + `${home}/.config/Code - Insiders/User/globalStorage/github.copilot-chat/copilotCli/copilot`, + `${home}/.config/Code/User/globalStorage/github.copilot-chat/copilotCli/copilot` + ); + } else if (isWindows && appData) { + staticLocations.push( + `${appData}\\Code - Insiders\\User\\globalStorage\\github.copilot-chat\\copilotCli\\copilot.bat`, + `${appData}\\Code\\User\\globalStorage\\github.copilot-chat\\copilotCli\\copilot.bat` + ); + } + + for (const location of staticLocations) { + try { + await fs.access(location); + candidates.push({ config: { cliPath: location }, source: "VS Code globalStorage" }); + logCopilotDebug(`discovered candidate from VS Code globalStorage: ${location}`); + } catch { + // Try next + } + } + + const exts = isWindows ? "{.exe,.bat,.cmd}" : ""; + const normalizedHome = home.replace(/\\/g, "/"); + const globPatterns = [ + `${normalizedHome}/.vscode-insiders/extensions/github.copilot-chat-*/copilotCli/copilot${exts}`, + `${normalizedHome}/.vscode/extensions/github.copilot-chat-*/copilotCli/copilot${exts}` + ]; + + for (const pattern of globPatterns) { + const matches = await fg(pattern, { onlyFiles: true }); + for (const match of matches) { + const normalized = path.normalize(match); + candidates.push({ + config: { cliPath: normalized }, + source: "VS Code extensions" + }); + logCopilotDebug(`discovered candidate from VS Code extensions: ${normalized}`); + } + } + + const compatible = await findFirstCompatibleCandidate(candidates); + if (compatible) { + const desc = compatible.config.cliArgs + ? `${compatible.config.cliPath} ${compatible.config.cliArgs.join(" ")}` + : compatible.config.cliPath; + logCopilotDebug(`selected compatible candidate from ${compatible.source}: ${desc}`); + return cacheConfig(compatible.config); + } + + if (candidates.length > 0) { + const first = candidates[0]; + const desc = first.config.cliArgs + ? `${first.config.cliPath} ${first.config.cliArgs.join(" ")}` + : first.config.cliPath; + throw new Error( + `Found Copilot CLI candidate from ${first.source} (${desc}) but it does not support '--headless'. ` + + "Primer requires a Copilot CLI build compatible with SDK server mode. " + + "Install/update GitHub Copilot Chat in VS Code, or point PRIMER_COPILOT_CLI_PATH to a compatible CLI binary." + ); + } + + const platformHint = isWindows + ? " Searched APPDATA and VS Code extension paths." + : process.platform === "linux" + ? " Searched ~/.config/Code and VS Code extension paths." + : " Searched ~/Library/Application Support/Code and VS Code extension paths."; + + throw new Error( + `Copilot CLI not found. Install GitHub Copilot Chat extension in VS Code or run: npm install -g @github/copilot.${platformHint}` + ); +} + +async function findFirstCompatibleCandidate( + candidates: CopilotCliCandidate[] +): Promise { + const seen = new Set(); + + for (const candidate of candidates) { + const key = [candidate.config.cliPath, ...(candidate.config.cliArgs ?? [])].join("\u0000"); + if (seen.has(key)) { + logCopilotDebug(`skipping duplicate candidate: ${candidate.config.cliPath}`); + continue; + } + seen.add(key); + + const compatible = await isHeadlessCompatible(candidate.config); + const desc = candidate.config.cliArgs + ? `${candidate.config.cliPath} ${candidate.config.cliArgs.join(" ")}` + : candidate.config.cliPath; + logCopilotDebug( + `probe ${candidate.source}: ${desc} => ${compatible ? "compatible" : "incompatible"}` + ); + if (compatible) { + return candidate; + } + } + + return null; +} + +async function isHeadlessCompatible(config: CopilotCliConfig): Promise { + try { + const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]); + await execFileAsync(cmd, args, { timeout: 5000 }); + return true; + } catch { + return false; + } +} + +function extractModelChoices(helpText: string): string[] { + const lines = helpText.split("\n"); + let captured = ""; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.includes("--model")) continue; + + captured = line.trim(); + while (!captured.includes(")") && index + 1 < lines.length) { + index += 1; + captured += ` ${lines[index].trim()}`; + } + break; + } + + const match = captured.match(/choices:\s*([^)]*)/); + if (!match) return []; + + const models: string[] = []; + const matcher = /"([^"]+)"/g; + let entry = matcher.exec(match[1]); + while (entry) { + models.push(entry[1]); + entry = matcher.exec(match[1]); + } + + return models; +} diff --git a/src/services/copilotSdk.ts b/src/services/copilotSdk.ts new file mode 100644 index 0000000..2fefadd --- /dev/null +++ b/src/services/copilotSdk.ts @@ -0,0 +1,191 @@ +import { spawn, type ChildProcess } from "node:child_process"; + +import type * as CopilotSdk from "@github/copilot-sdk"; + +import { buildExecArgs, logCopilotDebug, type CopilotCliConfig } from "./copilot"; + +export type CopilotSdkModule = typeof CopilotSdk; + +let cachedSdkModule: Promise | null = null; + +function normalizeSdkLoadError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + const isMissingModule = + message.includes("@github/copilot-sdk") && + /(Cannot find module|Cannot find package|ERR_MODULE_NOT_FOUND)/iu.test(message); + + if (!isMissingModule) { + return error instanceof Error ? error : new Error(message); + } + + return new Error( + "Copilot SDK package '@github/copilot-sdk' could not be loaded. " + + "Run `npm install` in this repository. " + + "If this is running inside the Primer VS Code extension, rebuild and reinstall the extension so the SDK is bundled (`cd vscode-extension && npm run build`)." + ); +} + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function shouldFallbackToExternalServer(error: unknown): boolean { + const message = normalizeError(error).message.toLowerCase(); + return ( + message.includes("unknown option '--headless'") || + message.includes("unknown option '--no-auto-update'") + ); +} + +async function startExternalServer(cliConfig: CopilotCliConfig): Promise<{ + cliProcess: ChildProcess; + cliUrl: string; +}> { + const [cmd, args] = buildExecArgs(cliConfig, ["--headless", "--log-level", "debug"]); + logCopilotDebug(`starting external CLI server: ${cmd} ${args.join(" ")}`); + + return await new Promise((resolve, reject) => { + const cliProcess = spawn(cmd, args, { + cwd: process.cwd(), + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + let timer: ReturnType | undefined; + + const finishReject = (reason: unknown): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (!cliProcess.killed) { + cliProcess.kill(); + } + reject(normalizeError(reason)); + }; + + const finishResolve = (port: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + // Stop accumulating output after settling to avoid unbounded memory growth + cliProcess.stdout?.removeAllListeners("data"); + cliProcess.stderr?.removeAllListeners("data"); + resolve({ + cliProcess, + cliUrl: `localhost:${port}` + }); + }; + + cliProcess.stdout?.on("data", (data: Buffer) => { + const chunk = data.toString(); + stdout += chunk; + const match = stdout.match(/listening on port (\d+)/iu); + if (match) { + finishResolve(match[1]); + } + }); + + cliProcess.stderr?.on("data", (data: Buffer) => { + const chunk = data.toString(); + stderr += chunk; + const lines = chunk.split("\n"); + for (const line of lines) { + if (line.trim()) { + process.stderr.write(`[CLI subprocess] ${line}\n`); + } + } + }); + + cliProcess.on("error", (error) => { + finishReject(error); + }); + + cliProcess.on("exit", (code) => { + if (settled) return; + const details = stderr.trim() || stdout.trim(); + finishReject( + new Error( + details + ? `External CLI server exited with code ${code}\n${details}` + : `External CLI server exited with code ${code}` + ) + ); + }); + + timer = setTimeout(() => { + finishReject(new Error("Timeout waiting for external CLI server to start")); + }, 10000); + }); +} + +function attachExternalServerCleanup( + client: InstanceType, + cliProcess: ChildProcess +): void { + const originalStop = client.stop.bind(client); + client.stop = (async () => { + const errors = await originalStop(); + if (!cliProcess.killed) { + cliProcess.kill(); + } + return errors; + }) as typeof client.stop; +} + +export async function loadCopilotSdk(): Promise { + if (!cachedSdkModule) { + cachedSdkModule = import("@github/copilot-sdk").catch((error) => { + cachedSdkModule = null; + throw normalizeSdkLoadError(error); + }); + } + + return cachedSdkModule; +} + +export async function createCopilotClient( + cliConfig: CopilotCliConfig +): Promise> { + const sdk = await loadCopilotSdk(); + const desc = cliConfig.cliArgs + ? `${cliConfig.cliPath} ${cliConfig.cliArgs.join(" ")}` + : cliConfig.cliPath; + logCopilotDebug(`creating SDK client with cliPath=${desc} useStdio=false`); + // Always pass an explicit CLI config so the SDK does not fall back to package-local CLI resolution. + // Use TCP transport because some VS Code Copilot CLI shims reject stdio mode. + const primaryClient = new sdk.CopilotClient({ ...cliConfig, useStdio: false }); + + try { + await primaryClient.start(); + return primaryClient; + } catch (error) { + if (!shouldFallbackToExternalServer(error)) { + throw normalizeError(error); + } + + logCopilotDebug("primary SDK-managed startup failed; falling back to external server mode"); + try { + await primaryClient.stop(); + } catch { + // Best-effort cleanup before fallback + } + + const external = await startExternalServer(cliConfig); + const fallbackClient = new sdk.CopilotClient({ cliUrl: external.cliUrl }); + try { + await fallbackClient.start(); + } catch (fallbackError) { + if (!external.cliProcess.killed) { + external.cliProcess.kill(); + } + throw normalizeError(fallbackError); + } + + attachExternalServerCleanup(fallbackClient, external.cliProcess); + return fallbackClient; + } +} diff --git a/src/services/evalScaffold.ts b/src/services/evalScaffold.ts new file mode 100644 index 0000000..01beec7 --- /dev/null +++ b/src/services/evalScaffold.ts @@ -0,0 +1,221 @@ +import { DEFAULT_MODEL } from "../config"; + +import type { Area } from "./analyzer"; +import { assertCopilotCliReady } from "./copilot"; +import { createCopilotClient } from "./copilotSdk"; + +const EVAL_SCAFFOLD_TIMEOUT_MS = 600000; +const EVAL_SCAFFOLD_RECOVERY_TIMEOUT_MS = 90000; + +export type EvalCase = { + id?: string; + prompt: string; + expectation: string; + area?: string; +}; + +export type EvalConfig = { + instructionFile?: string; + cases: EvalCase[]; + systemMessage?: string; + outputPath?: string; + ui?: { + modelPicker?: "visible" | "hidden"; + }; +}; + +type EvalScaffoldOptions = { + repoPath: string; + count: number; + model?: string; + areas?: Area[]; + onProgress?: (message: string) => void; +}; + +export async function generateEvalScaffold(options: EvalScaffoldOptions): Promise { + const repoPath = options.repoPath; + const count = Math.max(1, options.count); + const progress = options.onProgress ?? (() => {}); + + progress("Checking Copilot CLI..."); + const cliConfig = await assertCopilotCliReady(); + + progress("Starting Copilot SDK..."); + const client = await createCopilotClient(cliConfig); + + try { + progress("Creating session..."); + const preferredModel = options.model ?? DEFAULT_MODEL; + const session = await client.createSession({ + model: preferredModel, + streaming: true, + workingDirectory: repoPath, + systemMessage: { + content: + "You are an expert codebase analyst specializing in deep architectural analysis. Generate challenging, cross-cutting eval cases for this repository that require synthesizing information from multiple files and tracing logic across layers. Avoid trivial questions answerable from a single file read or grep. Use tools (glob, view, grep) extensively to inspect the codebase. Output ONLY JSON with keys: instructionFile, cases (array of {id,prompt,expectation})." + }, + infiniteSessions: { enabled: false } + }); + + let content = ""; + session.on((event: { type: string; data?: Record }) => { + if (event.type === "assistant.message_delta") { + const delta = event.data?.deltaContent as string | undefined; + if (delta) { + content += delta; + progress("Generating eval cases..."); + } + } else if (event.type === "tool.execution_start") { + const toolName = event.data?.toolName as string | undefined; + progress(`Using tool: ${toolName ?? "..."}`); + } else if (event.type === "session.error") { + const errorMsg = (event.data?.message as string) ?? "Unknown error"; + if (errorMsg.toLowerCase().includes("auth") || errorMsg.toLowerCase().includes("login")) { + throw new Error( + "Copilot CLI not logged in. Run `copilot` then `/login` to authenticate." + ); + } + } + }); + + const areaContext = options.areas?.length + ? [ + "", + "AREA CONTEXT:", + "This repo has the following areas:", + ...options.areas.map((a) => { + const patterns = Array.isArray(a.applyTo) ? a.applyTo.join(", ") : a.applyTo; + return `- ${a.name} (${patterns})`; + }), + "", + "Generate a mix of:", + "- Single-area cases that go deep into one area's internals", + "- Cross-area cases that test interactions between areas", + 'Include an optional "area" field in each case to tag which area(s) it targets.' + ].join("\n") + : ""; + + const prompt = [ + `Analyze this repository and generate ${count} eval cases.`, + "", + "IMPORTANT: Generate HARD eval cases that require deep, cross-cutting understanding of the codebase.", + "Each case should require synthesizing information from MULTIPLE files or tracing logic across several layers.", + "Do NOT generate simple questions that can be answered by reading a single file or running a single grep.", + "", + "Good eval case examples (adapt to this repo):", + "- Questions about how data flows end-to-end through multiple modules (e.g., 'Trace what happens when X is called — which services, transforms, and side effects are involved?')", + "- Questions about implicit conventions or patterns that span many files (e.g., 'What error-handling pattern is used across the service layer, and where does it deviate?')", + "- Questions requiring understanding of runtime behavior not obvious from static code (e.g., 'What is the order of initialization and what would break if module X loaded before Y?')", + "- Questions about non-obvious interactions between components (e.g., 'How does changing config option X affect the behavior of feature Y?')", + "- Questions about edge cases or failure modes that require reading implementation details across files", + "- Questions that require understanding the type system, generics, or shared interfaces across module boundaries", + "", + "Bad eval case examples (avoid these):", + "- 'What does this project do?' (answered by README alone)", + "- 'How do I build/test?' (answered by package.json alone)", + "- 'What is the entrypoint?' (answered by a single file)", + "- Any question answerable by reading one file or searching for one keyword", + "", + "Use tools extensively to inspect the codebase — read multiple files, trace imports, follow call chains.", + "If this is a monorepo (npm/pnpm/yarn workspaces, Cargo workspace, Go workspace, .NET solution, Gradle/Maven multi-module), generate cases that involve cross-app dependencies, shared libraries, and how changes in one app affect others.", + "Ensure cases cover cross-cutting concerns: data flow, error propagation, configuration impact, implicit coupling, architectural invariants.", + "Include a systemMessage that keeps answers scoped to this repository (avoid generic Copilot CLI details unless asked).", + "Return JSON ONLY (no markdown, no commentary) in this schema:", + '{\n "instructionFile": ".github/copilot-instructions.md",\n "systemMessage": "...",\n "cases": [\n {"id": "case-1", "prompt": "...", "expectation": "...", "area": "optional-area-name"}\n ]\n}', + areaContext + ].join("\n"); + + progress("Analyzing codebase..."); + let timedOutWaitingForIdle = false; + try { + await session.sendAndWait({ prompt }, EVAL_SCAFFOLD_TIMEOUT_MS); + } catch (error) { + if (!isSessionIdleTimeoutError(error)) { + throw error; + } + + timedOutWaitingForIdle = true; + progress("Generation took longer than expected; requesting final JSON output..."); + + try { + await session.sendAndWait( + { + prompt: + "Stop analysis and return only the final JSON scaffold now. Do not include markdown or commentary." + }, + EVAL_SCAFFOLD_RECOVERY_TIMEOUT_MS + ); + } catch (recoveryError) { + if (!isSessionIdleTimeoutError(recoveryError)) { + throw recoveryError; + } + progress("Still waiting on idle; attempting to parse partial output..."); + } + } finally { + await session.destroy(); + } + + let parsed: EvalConfig; + try { + parsed = parseEvalConfig(content); + } catch (error) { + if (timedOutWaitingForIdle) { + throw new Error( + "Timed out waiting for scaffold generation to become idle before a complete JSON payload was returned. Try again or lower `--count`." + ); + } + throw error; + } + + const normalized = normalizeEvalConfig(parsed, count); + return normalized; + } finally { + await client.stop(); + } +} + +function isSessionIdleTimeoutError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.message.toLowerCase(); + return message.includes("timeout") && message.includes("session.idle"); +} + +function parseEvalConfig(raw: string): EvalConfig { + const match = raw.match(/\{[\s\S]*\}/u); + if (!match) { + throw new Error("Failed to parse eval scaffold JSON."); + } + const parsed = JSON.parse(match[0]) as EvalConfig; + if (!parsed || !Array.isArray(parsed.cases)) { + throw new Error("Eval scaffold JSON is missing cases."); + } + return parsed; +} + +function normalizeEvalConfig(parsed: EvalConfig, count: number): EvalConfig { + const cases = (parsed.cases ?? []).slice(0, count).map((entry, index) => { + const id = typeof entry.id === "string" && entry.id.trim() ? entry.id : `case-${index + 1}`; + return { + id, + prompt: String(entry.prompt ?? "").trim(), + expectation: String(entry.expectation ?? "").trim(), + area: typeof entry.area === "string" && entry.area.trim() ? entry.area.trim() : undefined + }; + }); + + if (!cases.length) { + throw new Error("Eval scaffold JSON did not include any usable cases."); + } + + const defaultSystemMessage = + "You are answering questions about this repository. Use tools to inspect the repo and cite its files. Avoid generic Copilot CLI details unless the prompt explicitly asks for them."; + + return { + instructionFile: parsed.instructionFile ?? ".github/copilot-instructions.md", + systemMessage: parsed.systemMessage ?? defaultSystemMessage, + cases + }; +} diff --git a/src/services/evaluator.ts b/src/services/evaluator.ts index 6a30001..bf50d4f 100644 --- a/src/services/evaluator.ts +++ b/src/services/evaluator.ts @@ -1,21 +1,25 @@ import fs from "fs/promises"; import path from "path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -const execFileAsync = promisify(execFile); +import { buildTimestampedName, safeWriteFile } from "../utils/fs"; -type EvalCase = { - prompt: string; - expectation: string; - id?: string; -}; +import { assertCopilotCliReady } from "./copilot"; +import { createCopilotClient } from "./copilotSdk"; +import type { EvalConfig } from "./evalScaffold"; -type EvalConfig = { - instructionFile?: string; - cases: EvalCase[]; - systemMessage?: string; -}; +const DEFAULT_SYSTEM_MESSAGE = + "You are answering questions about this repository. Use tools to inspect the repo and cite its files. Avoid generic Copilot CLI details unless the prompt explicitly asks for them."; + +interface CopilotSession { + on(handler: (event: { type: string; data?: Record }) => void): void; + sendAndWait(params: { prompt: string }, timeoutMs?: number): Promise; + destroy(): Promise; +} + +interface CopilotClient { + createSession(config?: Record): Promise; + stop(): Promise; +} type EvalRunOptions = { configPath: string; @@ -26,6 +30,40 @@ type EvalRunOptions = { onProgress?: (message: string) => void; }; +type TokenUsage = { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +}; + +type ToolCallSummary = { + count: number; + byName: Record; + totalDurationMs: number; +}; + +type AskMetrics = { + durationMs: number; + tokenUsage?: TokenUsage; + toolCalls: ToolCallSummary; +}; + +type EvalMetrics = { + withoutInstructions: AskMetrics; + withInstructions: AskMetrics; + judge: AskMetrics; + totalDurationMs: number; +}; + +type EvalPhase = "withoutInstructions" | "withInstructions" | "judge"; + +type TrajectoryEvent = { + timestampMs: number; + phase: EvalPhase; + type: string; + data?: Record; +}; + export type EvalResult = { id: string; prompt: string; @@ -35,19 +73,32 @@ export type EvalResult = { verdict?: "pass" | "fail" | "unknown"; score?: number; rationale?: string; + metrics?: EvalMetrics; + trajectory?: TrajectoryEvent[]; }; -export async function runEval(options: EvalRunOptions): Promise<{ summary: string; results: EvalResult[] }> { +export async function runEval( + options: EvalRunOptions +): Promise<{ summary: string; results: EvalResult[]; viewerPath?: string }> { const config = await loadConfig(options.configPath); const instructionFile = config.instructionFile ?? ".github/copilot-instructions.md"; const instructionPath = path.resolve(options.repoPath, instructionFile); const instructionText = await readOptionalFile(instructionPath); + const baseSystemMessage = config.systemMessage ?? DEFAULT_SYSTEM_MESSAGE; const progress = options.onProgress ?? (() => {}); + const defaultOutputPath = path.resolve( + options.repoPath, + ".primer", + "evals", + buildTimestampedName("eval-results") + ); + const outputPath = + resolveOutputPath(options.repoPath, options.outputPath, config.outputPath) ?? defaultOutputPath; + const runStartedAt = Date.now(); progress("Starting Copilot SDK..."); - const cliPath = await findCopilotCliPath(); - const sdk = await import("@github/copilot-sdk"); - const client = new sdk.CopilotClient({ cliPath }); + const cliConfig = await assertCopilotCliReady(); + const client = await createCopilotClient(cliConfig); try { const results: EvalResult[] = []; @@ -56,19 +107,22 @@ export async function runEval(options: EvalRunOptions): Promise<{ summary: strin for (const [index, testCase] of config.cases.entries()) { const id = testCase.id ?? `case-${index + 1}`; const prompt = buildPrompt(options.repoPath, testCase.prompt); + const caseStartedAt = Date.now(); progress(`Running eval ${index + 1}/${total}: ${id} (without instructions)...`); - const withoutInstructions = await askOnce(client, { + const withoutResult = await askOnce(client, { prompt, model: options.model, - systemMessage: config.systemMessage + systemMessage: baseSystemMessage, + phase: "withoutInstructions" }); progress(`Running eval ${index + 1}/${total}: ${id} (with instructions)...`); - const withInstructions = await askOnce(client, { + const withResult = await askOnce(client, { prompt, model: options.model, - systemMessage: [config.systemMessage, instructionText].filter(Boolean).join("\n\n") + systemMessage: [baseSystemMessage, instructionText].filter(Boolean).join("\n\n"), + phase: "withInstructions" }); progress(`Running eval ${index + 1}/${total}: ${id} (judging)...`); @@ -76,36 +130,63 @@ export async function runEval(options: EvalRunOptions): Promise<{ summary: strin model: options.judgeModel, prompt: testCase.prompt, expectation: testCase.expectation, - withoutInstructions, - withInstructions + withoutInstructions: withoutResult.content, + withInstructions: withResult.content }); + const metrics: EvalMetrics = { + withoutInstructions: withoutResult.metrics, + withInstructions: withResult.metrics, + judge: judgment.metrics, + totalDurationMs: Date.now() - caseStartedAt + }; + + const trajectory = [ + ...withoutResult.trajectory, + ...withResult.trajectory, + ...judgment.trajectory + ]; + results.push({ id, prompt: testCase.prompt, expectation: testCase.expectation, - withInstructions, - withoutInstructions, - verdict: judgment.verdict, - score: judgment.score, - rationale: judgment.rationale + withInstructions: withResult.content, + withoutInstructions: withoutResult.content, + verdict: judgment.result.verdict, + score: judgment.result.score, + rationale: judgment.result.rationale, + metrics, + trajectory }); - progress(`Eval ${index + 1}/${total}: ${id} → ${judgment.verdict} (score: ${judgment.score})`); + progress( + `Eval ${index + 1}/${total}: ${id} → ${judgment.result.verdict} (score: ${judgment.result.score})` + ); } - if (options.outputPath) { - const output = { - repoPath: options.repoPath, - model: options.model, - judgeModel: options.judgeModel, - results - }; - await fs.writeFile(options.outputPath, JSON.stringify(output, null, 2), "utf8"); + const runFinishedAt = Date.now(); + const output = { + repoPath: options.repoPath, + model: options.model, + judgeModel: options.judgeModel, + runMetrics: { + startedAt: new Date(runStartedAt).toISOString(), + finishedAt: new Date(runFinishedAt).toISOString(), + durationMs: runFinishedAt - runStartedAt + }, + results + }; + let viewerPath: string | undefined; + if (outputPath) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await safeWriteFile(outputPath, JSON.stringify(output, null, 2), true); + viewerPath = buildViewerPath(outputPath); + await safeWriteFile(viewerPath, buildTrajectoryViewerHtml(output), true); } - const summary = formatSummary(results); - return { summary, results }; + const summary = formatSummary(results, runFinishedAt - runStartedAt); + return { summary, results, viewerPath }; } finally { await client.stop(); } @@ -115,23 +196,28 @@ type AskOptions = { prompt: string; model: string; systemMessage?: string; + phase: EvalPhase; +}; + +type AskResult = { + content: string; + metrics: AskMetrics; + trajectory: TrajectoryEvent[]; }; -async function askOnce( - client: { createSession: (config?: Record) => Promise }, - options: AskOptions -): Promise { +async function askOnce(client: CopilotClient, options: AskOptions): Promise { const session = await client.createSession({ model: options.model, streaming: true, infiniteSessions: { enabled: false }, - systemMessage: options.systemMessage - ? { content: options.systemMessage } - : undefined + systemMessage: options.systemMessage ? { content: options.systemMessage } : undefined }); let content = ""; + const telemetry = createTelemetry(options.phase); + const startedAt = Date.now(); session.on((event: { type: string; data?: Record }) => { + captureTelemetryEvent(event, telemetry); if (event.type === "assistant.message_delta") { const delta = event.data?.deltaContent as string | undefined; if (delta) content += delta; @@ -140,7 +226,16 @@ async function askOnce( await session.sendAndWait({ prompt: options.prompt }, 120000); await session.destroy(); - return content.trim(); + const finishedAt = Date.now(); + return { + content: content.trim(), + metrics: { + durationMs: finishedAt - startedAt, + tokenUsage: normalizeTokenUsage(telemetry.tokenUsage), + toolCalls: telemetry.toolCalls + }, + trajectory: telemetry.trajectory + }; } type JudgeOptions = { @@ -158,20 +253,24 @@ type JudgeResult = { }; async function judge( - client: { createSession: (config?: Record) => Promise }, + client: CopilotClient, options: JudgeOptions -): Promise { +): Promise<{ result: JudgeResult; metrics: AskMetrics; trajectory: TrajectoryEvent[] }> { const session = await client.createSession({ model: options.model, streaming: true, infiniteSessions: { enabled: false }, systemMessage: { - content: "You are a strict evaluator. Return JSON with keys: verdict (pass|fail|unknown), score (0-100), rationale. Do not include any other text." + content: + "You are a strict evaluator. Return JSON with keys: verdict (pass|fail|unknown), score (0-100), rationale. Do not include any other text." } }); let content = ""; + const telemetry = createTelemetry("judge"); + const startedAt = Date.now(); session.on((event: { type: string; data?: Record }) => { + captureTelemetryEvent(event, telemetry); if (event.type === "assistant.message_delta") { const delta = event.data?.deltaContent as string | undefined; if (delta) content += delta; @@ -195,7 +294,16 @@ async function judge( await session.sendAndWait({ prompt }, 120000); await session.destroy(); - return parseJudge(content); + const finishedAt = Date.now(); + return { + result: parseJudge(content), + metrics: { + durationMs: finishedAt - startedAt, + tokenUsage: normalizeTokenUsage(telemetry.tokenUsage), + toolCalls: telemetry.toolCalls + }, + trajectory: telemetry.trajectory + }; } function parseJudge(content: string): JudgeResult { @@ -220,37 +328,11 @@ function parseJudge(content: string): JudgeResult { async function loadConfig(configPath: string): Promise { const raw = await fs.readFile(configPath, "utf8"); - return JSON.parse(raw) as EvalConfig; -} - -async function findCopilotCliPath(): Promise { - // Try standard PATH first - try { - const { stdout } = await execFileAsync("which", ["copilot"], { timeout: 5000 }); - return stdout.trim(); - } catch { - // Ignore - will try VS Code location - } - - // VS Code Copilot Chat extension location - const home = process.env.HOME ?? ""; - const vscodeLocations = [ - `${home}/Library/Application Support/Code - Insiders/User/globalStorage/github.copilot-chat/copilotCli/copilot`, - `${home}/Library/Application Support/Code/User/globalStorage/github.copilot-chat/copilotCli/copilot`, - `${home}/.vscode-insiders/extensions/github.copilot-chat-*/copilotCli/copilot`, - `${home}/.vscode/extensions/github.copilot-chat-*/copilotCli/copilot`, - ]; - - for (const location of vscodeLocations) { - try { - await fs.access(location); - return location; - } catch { - // Try next location - } + const parsed = JSON.parse(raw) as EvalConfig; + if (!parsed || !Array.isArray(parsed.cases)) { + throw new Error("Eval config must have a 'cases' array."); } - - throw new Error("Copilot CLI not found. Install GitHub Copilot Chat extension in VS Code."); + return parsed; } async function readOptionalFile(filePath: string): Promise { @@ -271,21 +353,614 @@ function buildPrompt(repoPath: string, userPrompt: string): string { ].join("\n"); } -function formatSummary(results: EvalResult[]): string { +function formatSummary(results: EvalResult[], runDurationMs: number): string { const total = results.length; const passed = results.filter((r) => r.verdict === "pass").length; const failed = results.filter((r) => r.verdict === "fail").length; const unknown = results.filter((r) => r.verdict === "unknown").length; + const totalUsage = aggregateTokenUsage(results); + const hasUsage = Boolean( + totalUsage.promptTokens || totalUsage.completionTokens || totalUsage.totalTokens + ); const lines = [ - `Eval results: ${passed}/${total} pass, ${failed} fail, ${unknown} unknown.` + `Eval results: ${passed}/${total} pass, ${failed} fail, ${unknown} unknown.`, + `Runtime: ${formatDuration(runDurationMs)}.`, + hasUsage ? `Token usage: ${formatTokenUsage(totalUsage)}.` : "Token usage: unavailable." ]; for (const result of results) { - lines.push( - `- ${result.id}: ${result.verdict ?? "unknown"} (score: ${result.score ?? 0})` - ); + lines.push(`- ${result.id}: ${result.verdict ?? "unknown"} (score: ${result.score ?? 0})`); } return `\n${lines.join("\n")}`; } + +type TelemetryCollector = { + trajectory: TrajectoryEvent[]; + tokenUsage: TokenUsage; + toolCalls: ToolCallSummary; + toolCallMap: Map; + phase: EvalPhase; +}; + +function createTelemetry(phase: EvalPhase): TelemetryCollector { + return { + trajectory: [], + tokenUsage: {}, + toolCalls: { count: 0, byName: {}, totalDurationMs: 0 }, + toolCallMap: new Map(), + phase + }; +} + +function captureTelemetryEvent( + event: { type: string; data?: Record }, + telemetry: TelemetryCollector +): void { + const timestampMs = Date.now(); + telemetry.trajectory.push({ + timestampMs, + phase: telemetry.phase, + type: event.type, + data: sanitizeEventData(event.data) + }); + + if (event.type === "tool.execution_start") { + const toolName = (event.data?.toolName as string | undefined) ?? "unknown"; + const toolId = resolveToolId(event.data, toolName, telemetry.toolCallMap.size); + telemetry.toolCallMap.set(toolId, { name: toolName, startMs: timestampMs }); + telemetry.toolCalls.count += 1; + telemetry.toolCalls.byName[toolName] = (telemetry.toolCalls.byName[toolName] ?? 0) + 1; + } else if (event.type === "tool.execution_finish" || event.type === "tool.execution_error") { + const toolName = (event.data?.toolName as string | undefined) ?? "unknown"; + const toolId = resolveToolId(event.data, toolName, telemetry.toolCallMap.size); + const entry = + telemetry.toolCallMap.get(toolId) ?? findLatestToolByName(telemetry.toolCallMap, toolName); + if (entry) { + const durationMs = timestampMs - entry.startMs; + telemetry.toolCalls.totalDurationMs += durationMs; + telemetry.toolCallMap.delete(toolId); + } + } + + const usage = extractTokenUsage(event.data); + if (usage) { + telemetry.tokenUsage = mergeTokenUsage(telemetry.tokenUsage, usage); + } +} + +function resolveToolId( + data: Record | undefined, + toolName: string, + index: number +): string { + const rawId = data?.executionId ?? data?.toolCallId ?? data?.callId ?? data?.id; + if (typeof rawId === "string" || typeof rawId === "number") { + return String(rawId); + } + return `${toolName}-${index + 1}`; +} + +function findLatestToolByName( + map: Map, + toolName: string +): { name?: string; startMs: number } | undefined { + const entries = Array.from(map.values()).filter((entry) => entry.name === toolName); + return entries.at(-1); +} + +function extractTokenUsage(data: Record | undefined): TokenUsage | null { + if (!data) return null; + const usage = findUsageObject(data); + const promptTokens = getNumber( + usage?.prompt_tokens ?? usage?.promptTokens ?? data.promptTokens ?? data.inputTokens + ); + const completionTokens = getNumber( + usage?.completion_tokens ?? + usage?.completionTokens ?? + data.completionTokens ?? + data.outputTokens + ); + const totalTokens = getNumber(usage?.total_tokens ?? usage?.totalTokens ?? data.totalTokens); + + if (promptTokens == null && completionTokens == null && totalTokens == null) { + return null; + } + + return { + promptTokens: promptTokens ?? undefined, + completionTokens: completionTokens ?? undefined, + totalTokens: totalTokens ?? undefined + }; +} + +function findUsageObject(data: Record): Record | undefined { + const direct = (data.usage ?? data.tokenUsage ?? data.tokens) as + | Record + | undefined; + if (direct) return direct; + + const candidates = [data.response, data.result, data.message, data.metrics, data.output]; + + for (const candidate of candidates) { + if (candidate && typeof candidate === "object") { + const nested = + (candidate as Record).usage ?? + (candidate as Record).tokenUsage; + if (nested && typeof nested === "object") return nested as Record; + } + } + + return scanForUsage(data, 0); +} + +function scanForUsage(value: unknown, depth: number): Record | undefined { + if (!value || typeof value !== "object" || depth > 4) return undefined; + if (Array.isArray(value)) { + for (const entry of value) { + const found = scanForUsage(entry, depth + 1); + if (found) return found; + } + return undefined; + } + + const record = value as Record; + if (hasTokenFields(record)) return record; + + for (const entry of Object.values(record)) { + const found = scanForUsage(entry, depth + 1); + if (found) return found; + } + + return undefined; +} + +function hasTokenFields(record: Record): boolean { + const keys = Object.keys(record); + return ( + keys.includes("prompt_tokens") || + keys.includes("completion_tokens") || + keys.includes("total_tokens") || + keys.includes("promptTokens") || + keys.includes("completionTokens") || + keys.includes("totalTokens") || + keys.includes("inputTokens") || + keys.includes("outputTokens") + ); +} + +function getNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +// The SDK reports cumulative token counts per session, so we keep the peak (max) value +// rather than summing incremental deltas. +function mergeTokenUsage(existing: TokenUsage, next: TokenUsage): TokenUsage { + return { + promptTokens: Math.max(existing.promptTokens ?? 0, next.promptTokens ?? 0) || undefined, + completionTokens: + Math.max(existing.completionTokens ?? 0, next.completionTokens ?? 0) || undefined, + totalTokens: Math.max(existing.totalTokens ?? 0, next.totalTokens ?? 0) || undefined + }; +} + +function normalizeTokenUsage(usage: TokenUsage): TokenUsage | undefined { + if (!usage.promptTokens && !usage.completionTokens && !usage.totalTokens) return undefined; + if (!usage.totalTokens) { + const prompt = usage.promptTokens ?? 0; + const completion = usage.completionTokens ?? 0; + const total = prompt + completion; + return { + ...usage, + totalTokens: total || undefined + }; + } + return usage; +} + +function aggregateTokenUsage(results: EvalResult[]): TokenUsage { + const total: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + for (const result of results) { + const metrics = result.metrics; + if (!metrics) continue; + const usages = [ + metrics.withoutInstructions.tokenUsage, + metrics.withInstructions.tokenUsage, + metrics.judge.tokenUsage + ]; + for (const usage of usages) { + if (!usage) continue; + total.promptTokens = (total.promptTokens ?? 0) + (usage.promptTokens ?? 0); + total.completionTokens = (total.completionTokens ?? 0) + (usage.completionTokens ?? 0); + total.totalTokens = (total.totalTokens ?? 0) + (usage.totalTokens ?? 0); + } + } + return total; +} + +function formatDuration(durationMs: number): string { + const seconds = Math.round(durationMs / 100) / 10; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = Math.round((seconds % 60) * 10) / 10; + return `${minutes}m ${remaining}s`; +} + +function formatTokenUsage(usage: TokenUsage): string { + const prompt = usage.promptTokens ?? 0; + const completion = usage.completionTokens ?? 0; + const total = usage.totalTokens ?? prompt + completion; + return `prompt ${prompt}, completion ${completion}, total ${total}`; +} + +function resolveOutputPath( + repoPath: string, + override?: string, + configValue?: string +): string | undefined { + const chosen = override ?? configValue; + if (!chosen) return undefined; + return path.isAbsolute(chosen) ? chosen : path.resolve(repoPath, chosen); +} + +function buildViewerPath(outputPath: string): string { + if (outputPath.endsWith(".json")) { + return outputPath.replace(/\.json$/u, ".html"); + } + return `${outputPath}.html`; +} + +function buildTrajectoryViewerHtml(data: Record): string { + const serialized = JSON.stringify(data).replace(/ + + + + + +Primer Eval Results + + + +
+
+ +
+

Eval Results

+
+
+
+ +
+ +
+ +
+
Impact of Instructions
+
+
+ +
+
Results by Case
+
+
+ +
+
Case Details
+
+
+ + + +`; +} + +function sanitizeEventData( + data: Record | undefined +): Record | undefined { + if (!data) return undefined; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (key === "deltaContent" && typeof value === "string") { + sanitized.deltaChars = value.length; + sanitized.deltaPreview = value.slice(0, 120); + continue; + } + sanitized[key] = sanitizeValue(value, 0); + } + return sanitized; +} + +function sanitizeValue(value: unknown, depth: number): unknown { + if (depth > 4) return "[depth-limit]"; + if (typeof value === "string") { + return value.length > 2000 ? `${value.slice(0, 2000)}…` : value; + } + if (Array.isArray(value)) { + return value.slice(0, 50).map((entry) => sanitizeValue(entry, depth + 1)); + } + if (value && typeof value === "object") { + const obj: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + obj[key] = sanitizeValue(entry, depth + 1); + } + return obj; + } + return value; +} diff --git a/src/services/generator.ts b/src/services/generator.ts index 27c5c07..eaedef1 100644 --- a/src/services/generator.ts +++ b/src/services/generator.ts @@ -1,7 +1,18 @@ import path from "path"; -import { RepoAnalysis } from "./analyzer"; + import { ensureDir, safeWriteFile } from "../utils/fs"; +import type { RepoAnalysis } from "./analyzer"; + +export type FileAction = { + path: string; + action: "wrote" | "skipped"; +}; + +export type GenerateResult = { + files: FileAction[]; +}; + export type GenerateOptions = { repoPath: string; analysis: RepoAnalysis; @@ -9,30 +20,33 @@ export type GenerateOptions = { force: boolean; }; -export async function generateConfigs(options: GenerateOptions): Promise<{ summary: string }> -{ +export async function generateConfigs(options: GenerateOptions): Promise { const { repoPath, analysis, selections, force } = options; - const actions: string[] = []; + const files: FileAction[] = []; if (selections.includes("mcp")) { const filePath = path.join(repoPath, ".vscode", "mcp.json"); await ensureDir(path.dirname(filePath)); const content = renderMcp(); - const result = await safeWriteFile(filePath, content, force); - actions.push(result); + const { wrote } = await safeWriteFile(filePath, content, force); + files.push({ + path: path.relative(process.cwd(), filePath), + action: wrote ? "wrote" : "skipped" + }); } if (selections.includes("vscode")) { const filePath = path.join(repoPath, ".vscode", "settings.json"); await ensureDir(path.dirname(filePath)); const content = renderVscodeSettings(analysis); - const result = await safeWriteFile(filePath, content, force); - actions.push(result); + const { wrote } = await safeWriteFile(filePath, content, force); + files.push({ + path: path.relative(process.cwd(), filePath), + action: wrote ? "wrote" : "skipped" + }); } - - const summary = actions.length ? `\n${actions.join("\n")}` : "No changes made."; - return { summary }; + return { files }; } function renderMcp(): string { @@ -74,9 +88,7 @@ function renderVscodeSettings(analysis: RepoAnalysis): string { "github.copilot.chat.codeGeneration.instructions": [ { file: ".github/copilot-instructions.md" } ], - "github.copilot.chat.reviewSelection.instructions": [ - { text: reviewFocus } - ], + "github.copilot.chat.reviewSelection.instructions": [{ text: reviewFocus }], "chat.promptFiles": true, "chat.mcp.enabled": true }, diff --git a/src/services/git.ts b/src/services/git.ts index a8b1e79..56809bd 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -1,6 +1,8 @@ import fs from "fs/promises"; import path from "path"; -import simpleGit, { SimpleGitProgressEvent } from "simple-git"; + +import type { SimpleGitProgressEvent } from "simple-git"; +import simpleGit from "simple-git"; export async function isGitRepo(repoPath: string): Promise { try { @@ -11,12 +13,6 @@ export async function isGitRepo(repoPath: string): Promise { } } -export async function getRepoRoot(repoPath: string): Promise { - const git = simpleGit(repoPath); - const root = await git.revparse(["--show-toplevel"]); - return root.trim(); -} - export type CloneOptions = { shallow?: boolean; timeoutMs?: number; @@ -24,16 +20,18 @@ export type CloneOptions = { }; export async function cloneRepo( - repoUrl: string, + repoUrl: string, destination: string, options: CloneOptions = {} ): Promise { const { shallow = true, timeoutMs = 60000, onProgress } = options; - + const git = simpleGit({ - progress: onProgress ? ({ stage, progress }: SimpleGitProgressEvent) => { - onProgress(stage, progress); - } : undefined, + progress: onProgress + ? ({ stage, progress }: SimpleGitProgressEvent) => { + onProgress(stage, progress); + } + : undefined, timeout: { block: timeoutMs } @@ -47,6 +45,15 @@ export async function cloneRepo( await git.clone(repoUrl, destination, cloneArgs); } +/** + * Replace the remote origin URL, typically to strip embedded credentials + * after cloning with an authenticated URL. + */ +export async function setRemoteUrl(repoPath: string, url: string): Promise { + const git = simpleGit(repoPath); + await git.remote(["set-url", "origin", url]); +} + export async function checkoutBranch(repoPath: string, branch: string): Promise { const git = simpleGit(repoPath); const branches = await git.branchLocal(); @@ -65,6 +72,8 @@ export async function commitAll(repoPath: string, message: string): Promise { +export function buildAuthedUrl(url: string, token: string, provider: AuthProvider): string { + const normalizedUrl = normalizeGitUrl(url); + if (!normalizedUrl.startsWith("https://")) return normalizedUrl; + if (provider === "azure") { + return normalizedUrl.replace("https://", `https://pat:${token}@`); + } + return normalizedUrl.replace("https://", `https://x-access-token:${token}@`); +} + +export async function pushBranch( + repoPath: string, + branch: string, + token?: string, + provider: AuthProvider = "github" +): Promise { const git = simpleGit(repoPath); - + if (token) { // Set up credentials for this push const remoteUrl = (await git.remote(["get-url", "origin"])) ?? ""; const normalizedUrl = normalizeGitUrl(remoteUrl); if (normalizedUrl.startsWith("https://")) { - const authedUrl = normalizedUrl.replace("https://", `https://x-access-token:${token}@`); + const authedUrl = buildAuthedUrl(normalizedUrl, token, provider); await git.remote(["set-url", "origin", authedUrl]); try { await git.push(["-u", "origin", branch]); + } catch (err) { + // Strip embedded credentials from error messages to avoid leaking tokens + const sanitized = + err instanceof Error + ? new Error(err.message.replace(/https:\/\/[^@]+@/g, "https://***@")) + : err; + throw sanitized; } finally { // Restore original URL to avoid leaking token await git.remote(["set-url", "origin", normalizedUrl]); @@ -96,6 +128,6 @@ export async function pushBranch(repoPath: string, branch: string, token?: strin return; } } - + await git.push(["-u", "origin", branch]); } diff --git a/src/services/github.ts b/src/services/github.ts index fbabaea..ffc7a6b 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,7 +1,8 @@ -import { Octokit } from "@octokit/rest"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { Octokit } from "@octokit/rest"; + const execFileAsync = promisify(execFile); /** @@ -45,7 +46,7 @@ export function createGitHubClient(token: string): Octokit { export async function listAccessibleRepos(token: string, limit = 100): Promise { const client = createGitHubClient(token); - + // Fetch only first page - avoids timeout for users with many repos const repos = await client.rest.repos.listForAuthenticatedUser({ visibility: "all", @@ -113,13 +114,13 @@ export async function listUserOrgs(token: string): Promise { return orgs.map((org) => ({ login: org.login, - name: org.name ?? null + name: org.description ?? null })); } export async function listOrgRepos(token: string, org: string, limit = 100): Promise { const client = createGitHubClient(token); - + // Fetch only the first page(s) up to limit - avoids timeout on huge orgs const repos = await client.rest.repos.listForOrg({ org, @@ -141,7 +142,11 @@ export async function listOrgRepos(token: string, org: string, limit = 100): Pro /** * Check if a repo has .github/copilot-instructions.md */ -export async function checkRepoHasInstructions(token: string, owner: string, repo: string): Promise { +export async function checkRepoHasInstructions( + token: string, + owner: string, + repo: string +): Promise { const client = createGitHubClient(token); try { await client.rest.repos.getContent({ @@ -150,8 +155,16 @@ export async function checkRepoHasInstructions(token: string, owner: string, rep path: ".github/copilot-instructions.md" }); return true; - } catch { - return false; + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "status" in error && + (error as { status: number }).status === 404 + ) { + return false; + } + throw error; } } @@ -159,8 +172,8 @@ export async function checkRepoHasInstructions(token: string, owner: string, rep * Check multiple repos for instructions in parallel (with concurrency limit) */ export async function checkReposForInstructions( - token: string, - repos: GitHubRepo[], + token: string, + repos: GitHubRepo[], onProgress?: (checked: number, total: number) => void ): Promise { const concurrency = 10; diff --git a/src/services/instructions.ts b/src/services/instructions.ts index a1ea882..3ed8f4d 100644 --- a/src/services/instructions.ts +++ b/src/services/instructions.ts @@ -1,45 +1,47 @@ -import fs from "fs/promises"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import path from "path"; + +import { DEFAULT_MODEL } from "../config"; +import { ensureDir, safeWriteFile } from "../utils/fs"; + +import type { Area } from "./analyzer"; +import { sanitizeAreaName } from "./analyzer"; +import { assertCopilotCliReady } from "./copilot"; +import { createCopilotClient } from "./copilotSdk"; type GenerateInstructionsOptions = { repoPath: string; - instructionFile?: string; model?: string; onProgress?: (message: string) => void; }; -export async function generateCopilotInstructions(options: GenerateInstructionsOptions): Promise { +export async function generateCopilotInstructions( + options: GenerateInstructionsOptions +): Promise { const repoPath = options.repoPath; const progress = options.onProgress ?? (() => {}); - const originalCwd = process.cwd(); - process.chdir(repoPath); - progress("Checking Copilot CLI..."); - const cliPath = await assertCopilotCliReady(); + const cliConfig = await assertCopilotCliReady(); progress("Starting Copilot SDK..."); - const sdk = await import("@github/copilot-sdk"); - const client = new sdk.CopilotClient({ - cliPath, - }); + const client = await createCopilotClient(cliConfig); try { progress("Creating session..."); - // Try requested model, fall back to gpt-4.1 if gpt-5 fails - const preferredModel = options.model ?? "gpt-4.1"; + const preferredModel = options.model ?? DEFAULT_MODEL; const session = await client.createSession({ model: preferredModel, streaming: true, + workingDirectory: repoPath, systemMessage: { - content: "You are an expert codebase analyst. Your task is to generate a concise .github/copilot-instructions.md file. Use the available tools (glob, view, grep) to explore the codebase. Output ONLY the final markdown content, no explanations.", + content: + "You are an expert codebase analyst. Your task is to generate a concise .github/copilot-instructions.md file. Use the available tools (glob, view, grep) to explore the codebase. Output ONLY the final markdown content, no explanations." }, - infiniteSessions: { enabled: false }, + infiniteSessions: { enabled: false } }); let content = ""; - + // Subscribe to events for progress and to capture content session.on((event) => { const e = event as { type: string; data?: Record }; @@ -55,26 +57,30 @@ export async function generateCopilotInstructions(options: GenerateInstructionsO } else if (e.type === "session.error") { const errorMsg = (e.data?.message as string) ?? "Unknown error"; if (errorMsg.toLowerCase().includes("auth") || errorMsg.toLowerCase().includes("login")) { - throw new Error("Copilot CLI not logged in. Run `copilot` then `/login` to authenticate."); + throw new Error( + "Copilot CLI not logged in. Run `copilot` then `/login` to authenticate." + ); } } }); // Simple prompt - let the agent use tools to explore - const prompt = `Analyze this codebase at ${repoPath} and generate a .github/copilot-instructions.md file. + const prompt = `Analyze this codebase and generate a .github/copilot-instructions.md file. -Use tools to explore: +Fan out multiple Explore subagents to map out the codebase in parallel: 1. Check for existing instruction files: glob for **/{.github/copilot-instructions.md,AGENT.md,CLAUDE.md,.cursorrules,README.md} -2. Identify the tech stack: look at package.json, tsconfig.json, pyproject.toml, Cargo.toml, etc. +2. Identify the tech stack: look at package.json, tsconfig.json, pyproject.toml, Cargo.toml, go.mod, *.csproj, *.sln, build.gradle, pom.xml, etc. 3. Understand the structure: list key directories +4. Detect monorepo structures: check for workspace configs (npm/pnpm/yarn workspaces, Cargo.toml [workspace], go.work, .sln solution files, settings.gradle include directives, pom.xml modules) Generate concise instructions (~20-50 lines) covering: - Tech stack and architecture -- Build/test commands +- Build/test commands - Project-specific conventions - Key files/directories +- Monorepo structure and per-app layout (if this is a monorepo, describe the workspace organization, how apps relate to each other, and any shared libraries) -Output ONLY the markdown content for the instructions file.`; +Output ONLY the markdown content for the instructions file, not wrapped in markdown code fences.`; progress("Analyzing codebase..."); await session.sendAndWait({ prompt }, 180000); @@ -83,52 +89,160 @@ Output ONLY the markdown content for the instructions file.`; return content.trim() || ""; } finally { await client.stop(); - process.chdir(originalCwd); } } -const execFileAsync = promisify(execFile); +type GenerateAreaInstructionsOptions = { + repoPath: string; + area: Area; + model?: string; + onProgress?: (message: string) => void; +}; + +export async function generateAreaInstructions( + options: GenerateAreaInstructionsOptions +): Promise { + const { repoPath, area } = options; + const progress = options.onProgress ?? (() => {}); + + progress(`Checking Copilot CLI for area "${area.name}"...`); + const cliConfig = await assertCopilotCliReady(); + + progress(`Starting Copilot SDK for area "${area.name}"...`); + const client = await createCopilotClient(cliConfig); -async function findCopilotCliPath(): Promise { - // Try standard PATH first try { - const { stdout } = await execFileAsync("which", ["copilot"], { timeout: 5000 }); - return stdout.trim(); - } catch { - // Ignore - will try VS Code location - } + const applyToPatterns = Array.isArray(area.applyTo) ? area.applyTo : [area.applyTo]; + const applyToStr = applyToPatterns.join(", "); - // VS Code Copilot Chat extension location - const home = process.env.HOME ?? ""; - const vscodeLocations = [ - `${home}/Library/Application Support/Code - Insiders/User/globalStorage/github.copilot-chat/copilotCli/copilot`, - `${home}/Library/Application Support/Code/User/globalStorage/github.copilot-chat/copilotCli/copilot`, - `${home}/.vscode-insiders/extensions/github.copilot-chat-*/copilotCli/copilot`, - `${home}/.vscode/extensions/github.copilot-chat-*/copilotCli/copilot`, - ]; - - for (const location of vscodeLocations) { - try { - await fs.access(location); - return location; - } catch { - // Try next location - } + progress(`Creating session for area "${area.name}"...`); + const preferredModel = options.model ?? DEFAULT_MODEL; + const session = await client.createSession({ + model: preferredModel, + streaming: true, + workingDirectory: repoPath, + systemMessage: { + content: `You are an expert codebase analyst. Your task is to generate a concise .instructions.md file for a specific area of a codebase. This file will be used as a file-based custom instruction in VS Code Copilot, automatically applied when working on files matching certain patterns. Use the Explore subagents and read-only tools to explore the codebase. Output ONLY the final markdown content, not wrapped in markdown code fences.` + }, + infiniteSessions: { enabled: false } + }); + + let content = ""; + + session.on((event) => { + const e = event as { type: string; data?: Record }; + if (e.type === "assistant.message_delta") { + const delta = e.data?.deltaContent as string | undefined; + if (delta) { + content += delta; + progress(`Generating instructions for "${area.name}"...`); + } + } else if (e.type === "tool.execution_start") { + const toolName = e.data?.toolName as string | undefined; + progress(`${area.name}: using tool ${toolName ?? "..."}`); + } else if (e.type === "session.error") { + const errorMsg = (e.data?.message as string) ?? "Unknown error"; + if (errorMsg.toLowerCase().includes("auth") || errorMsg.toLowerCase().includes("login")) { + throw new Error( + "Copilot CLI not logged in. Run `copilot` then `/login` to authenticate." + ); + } + } + }); + + const prompt = `Analyze the "${area.name}" area of this codebase and generate a file-based instruction file. + +This area covers files matching: ${applyToStr} +${area.description ? `Description: ${area.description}` : ""} + +Use tools to explore ONLY the files and directories within this area: +1. List the key files: glob for ${applyToPatterns.map((p) => `"${p}"`).join(", ")} +2. Identify the tech stack, dependencies, and frameworks used in this area +3. Look at key source files to understand patterns and conventions specific to this area + +Generate concise instructions (~10-30 lines) covering: +- What this area does and its role in the overall project +- Area-specific tech stack, dependencies, and frameworks +- Coding conventions and patterns specific to this area +- Build/test commands relevant to this area (if different from root) +- Key files and directory structure within this area + +IMPORTANT: +- Focus ONLY on this specific area, not the whole repo +- Do NOT repeat repo-wide information (that goes in the root copilot-instructions.md) +- Keep it complementary to root instructions +- Output ONLY the markdown content, no YAML frontmatter, no code fences`; + + progress(`Analyzing area "${area.name}"...`); + await session.sendAndWait({ prompt }, 180000); + await session.destroy(); + + return content.trim() || ""; + } finally { + await client.stop(); } +} - throw new Error("Copilot CLI not found. Install GitHub Copilot Chat extension in VS Code."); +function escapeYamlString(value: string): string { + return value + .replace(/\0/gu, "") + .replace(/\\/gu, "\\\\") + .replace(/"/gu, '\\"') + .replace(/\n/gu, "\\n") + .replace(/\r/gu, "\\r") + .replace(/\t/gu, "\\t"); } -async function assertCopilotCliReady(): Promise { - const cliPath = await findCopilotCliPath(); - - try { - await execFileAsync(cliPath, ["--version"], { timeout: 5000 }); - } catch { - throw new Error(`Copilot CLI at ${cliPath} is not working.`); - } +export function buildAreaFrontmatter(area: Area): string { + const applyTo = Array.isArray(area.applyTo) ? area.applyTo : [area.applyTo]; + const applyToValue = + applyTo.length === 1 + ? `"${escapeYamlString(applyTo[0])}"` + : `[${applyTo.map((p) => `"${escapeYamlString(p)}"`).join(", ")}]`; + const desc = area.description + ? `Use when working on ${area.name}. ${area.description}` + : `Use when working on ${area.name}`; + + return `--- +description: "${escapeYamlString(desc)}" +applyTo: ${applyToValue} +---`; +} - // Note: Copilot CLI uses its own auth system, not gh CLI. - // User must run: copilot, then /login inside the CLI. - return cliPath; -} \ No newline at end of file +export function buildAreaInstructionContent(area: Area, body: string): string { + return `${buildAreaFrontmatter(area)}\n\n${body}\n`; +} + +export function areaInstructionPath(repoPath: string, area: Area): string { + return path.join( + repoPath, + ".github", + "instructions", + `${sanitizeAreaName(area.name)}.instructions.md` + ); +} + +export type WriteAreaResult = { + status: "written" | "skipped" | "symlink" | "empty"; + filePath: string; +}; + +export async function writeAreaInstruction( + repoPath: string, + area: Area, + body: string, + force?: boolean +): Promise { + const filePath = areaInstructionPath(repoPath, area); + if (!body.trim()) return { status: "empty", filePath }; + await ensureDir(path.dirname(filePath)); + const { wrote, reason } = await safeWriteFile( + filePath, + buildAreaInstructionContent(area, body), + Boolean(force) + ); + if (!wrote) { + return { status: reason === "symlink" ? "symlink" : "skipped", filePath }; + } + return { status: "written", filePath }; +} diff --git a/src/services/policy.ts b/src/services/policy.ts new file mode 100644 index 0000000..53e23a1 --- /dev/null +++ b/src/services/policy.ts @@ -0,0 +1,308 @@ +import fs from "fs/promises"; +import path from "path"; + +import { readJson } from "../utils/fs"; + +import type { ReadinessCriterion, ReadinessContext } from "./readiness"; + +// ─── Policy configuration types ─── + +type CriterionMetadata = Pick< + ReadinessCriterion, + "title" | "pillar" | "level" | "scope" | "impact" | "effort" +>; + +export type ExtraDefinition = { + id: string; + title: string; + check: (context: ReadinessContext) => Promise<{ status: "pass" | "fail"; reason?: string }>; +}; + +export type PolicyConfig = { + name: string; + version?: string; + criteria?: { + disable?: string[]; + add?: ReadinessCriterion[]; + override?: Record>; + }; + extras?: { + disable?: string[]; + add?: ExtraDefinition[]; + }; + thresholds?: { + passRate?: number; + }; +}; + +export type ResolvedPolicy = { + chain: string[]; + criteria: ReadinessCriterion[]; + extras: ExtraDefinition[]; + thresholds: { passRate: number }; +}; + +// ─── Default thresholds ─── + +const DEFAULT_PASS_RATE = 0.8; + +// ─── Validation ─── + +function validatePolicyConfig( + obj: unknown, + source: string, + format: "json" | "module" = "module" +): PolicyConfig { + if (typeof obj !== "object" || obj === null) { + throw new Error(`Policy "${source}" is invalid: expected an object, got ${typeof obj}`); + } + const record = obj as Record; + if (typeof record.name !== "string" || !record.name.trim()) { + throw new Error(`Policy "${source}" is invalid: missing required field "name" at root`); + } + if (record.criteria !== undefined) { + if (typeof record.criteria !== "object") { + throw new Error(`Policy "${source}" is invalid: "criteria" must be an object`); + } + const criteria = record.criteria as Record; + if (criteria.disable !== undefined && !isStringArray(criteria.disable)) { + throw new Error( + `Policy "${source}" is invalid: "criteria.disable" must be an array of strings` + ); + } + if (criteria.override !== undefined) { + if ( + typeof criteria.override !== "object" || + criteria.override === null || + Array.isArray(criteria.override) + ) { + throw new Error(`Policy "${source}" is invalid: "criteria.override" must be an object`); + } + const ALLOWED_OVERRIDE_KEYS = new Set([ + "title", + "pillar", + "level", + "scope", + "impact", + "effort" + ]); + for (const [id, value] of Object.entries( + criteria.override as Record> + )) { + if (typeof value !== "object" || value === null) continue; + for (const key of Object.keys(value)) { + if (!ALLOWED_OVERRIDE_KEYS.has(key)) { + throw new Error( + `Policy "${source}" is invalid: "criteria.override.${id}" contains disallowed key "${key}". Allowed keys: ${[...ALLOWED_OVERRIDE_KEYS].join(", ")}` + ); + } + } + } + } + if (format === "json" && criteria.add !== undefined) { + throw new Error( + `Policy "${source}" is invalid: "criteria.add" is not supported in JSON policies (check functions cannot be serialized). Use a .ts or .js policy file instead.` + ); + } + } + if (record.extras !== undefined) { + if (typeof record.extras !== "object" || record.extras === null) { + throw new Error(`Policy "${source}" is invalid: "extras" must be an object`); + } + const extras = record.extras as Record; + if (extras.disable !== undefined && !isStringArray(extras.disable)) { + throw new Error( + `Policy "${source}" is invalid: "extras.disable" must be an array of strings` + ); + } + if (format === "json" && extras.add !== undefined) { + throw new Error( + `Policy "${source}" is invalid: "extras.add" is not supported in JSON policies (check functions cannot be serialized). Use a .ts or .js policy file instead.` + ); + } + } + if (record.thresholds !== undefined) { + if (typeof record.thresholds !== "object" || record.thresholds === null) { + throw new Error(`Policy "${source}" is invalid: "thresholds" must be an object`); + } + const thresholds = record.thresholds as Record; + if (thresholds.passRate !== undefined && typeof thresholds.passRate !== "number") { + throw new Error(`Policy "${source}" is invalid: "thresholds.passRate" must be a number`); + } + if ( + typeof thresholds.passRate === "number" && + (thresholds.passRate < 0 || thresholds.passRate > 1) + ) { + throw new Error( + `Policy "${source}" is invalid: "thresholds.passRate" must be between 0 and 1` + ); + } + } + return record as unknown as PolicyConfig; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +// ─── Helpers ─── + +export function parsePolicySources(raw: string | undefined): string[] | undefined { + if (!raw) return undefined; + const sources = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return sources.length ? sources : undefined; +} + +// ─── Loading ─── + +export async function loadPolicy( + source: string, + options?: { jsonOnly?: boolean } +): Promise { + const jsonOnly = options?.jsonOnly ?? false; + + // Local file path (relative or absolute) + if (source.startsWith(".") || path.isAbsolute(source)) { + const resolved = path.resolve(source); + if (resolved.endsWith(".json")) { + const data = await readJson(resolved); + if (!data) { + throw new Error(`Policy "${source}" not found at: ${resolved}`); + } + return validatePolicyConfig(data, source, "json"); + } + // TS/JS module — blocked when jsonOnly + if (/\.[mc]?[jt]s$/u.test(resolved)) { + if (jsonOnly) { + throw new Error( + `Policy "${source}" rejected: only JSON policies are allowed from primer.config.json. Module policies (.ts/.js) must be passed via --policy.` + ); + } + try { + const mod = (await import(resolved)) as Record; + const config = (mod.default ?? mod) as unknown; + return validatePolicyConfig(config, source); + } catch (err) { + if ( + err instanceof Error && + (err.message.includes("Cannot find module") || err.message.includes("MODULE_NOT_FOUND")) + ) { + throw new Error(`Policy "${source}" not found at: ${resolved}`); + } + throw err; + } + } + // Unsupported extension — try as JSON + try { + const raw = await fs.readFile(resolved, "utf8"); + const data = JSON.parse(raw) as unknown; + return validatePolicyConfig(data, source, "json"); + } catch { + throw new Error( + `Policy "${source}" could not be loaded from: ${resolved}. Supported formats: .json, .js, .ts, .mjs` + ); + } + } + + // npm package (bare specifier or scoped) — blocked when jsonOnly + if (jsonOnly) { + throw new Error( + `Policy "${source}" rejected: only JSON file policies are allowed from primer.config.json. npm policies must be passed via --policy.` + ); + } + try { + const mod = (await import(source)) as Record; + const config = (mod.default ?? mod) as unknown; + return validatePolicyConfig(config, source); + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === "object" && err !== null && "message" in err + ? String((err as { message: unknown }).message) + : String(err); + if ( + message.includes("Cannot find module") || + message.includes("Cannot find package") || + message.includes("MODULE_NOT_FOUND") || + message.includes("ERR_MODULE_NOT_FOUND") + ) { + throw new Error(`Policy "${source}" not found. Install it with: npm install ${source}`); + } + throw err; + } +} + +// ─── Chain resolution ─── + +export function resolveChain( + baseCriteria: ReadinessCriterion[], + baseExtras: ExtraDefinition[], + policies: PolicyConfig[] +): ResolvedPolicy { + const chain: string[] = []; + let criteria = [...baseCriteria]; + let extras = [...baseExtras]; + let passRate = DEFAULT_PASS_RATE; + + for (const policy of policies) { + chain.push(policy.name); + + if (policy.criteria) { + // Disable criteria by id + if (policy.criteria.disable?.length) { + const disableSet = new Set(policy.criteria.disable); + criteria = criteria.filter((c) => !disableSet.has(c.id)); + } + + // Override metadata by id + if (policy.criteria.override) { + for (const [id, overrides] of Object.entries(policy.criteria.override)) { + const idx = criteria.findIndex((c) => c.id === id); + if (idx >= 0) { + criteria[idx] = { ...criteria[idx], ...overrides }; + } + } + } + + // Add new criteria + if (policy.criteria.add?.length) { + for (const newCriterion of policy.criteria.add) { + // Replace if same id exists, otherwise append + const existingIdx = criteria.findIndex((c) => c.id === newCriterion.id); + if (existingIdx >= 0) { + criteria[existingIdx] = newCriterion; + } else { + criteria.push(newCriterion); + } + } + } + } + + if (policy.extras) { + if (policy.extras.disable?.length) { + const disableSet = new Set(policy.extras.disable); + extras = extras.filter((e) => !disableSet.has(e.id)); + } + if (policy.extras.add?.length) { + for (const newExtra of policy.extras.add) { + const existingIdx = extras.findIndex((e) => e.id === newExtra.id); + if (existingIdx >= 0) { + extras[existingIdx] = newExtra; + } else { + extras.push(newExtra); + } + } + } + } + + if (policy.thresholds?.passRate !== undefined) { + passRate = policy.thresholds.passRate; + } + } + + return { chain, criteria, extras, thresholds: { passRate } }; +} diff --git a/src/services/policy/adapter.ts b/src/services/policy/adapter.ts new file mode 100644 index 0000000..83ee5a7 --- /dev/null +++ b/src/services/policy/adapter.ts @@ -0,0 +1,192 @@ +/** + * Phase G: Compatibility adapter. + * + * Maps `EngineReport` from the new plugin system to the legacy + * `ReadinessReport` shape so all existing surfaces (CLI, HTML, VS Code) + * continue to work without changes. + * + * Key mapping rules: + * - Signal metadata carries pillar/level/scope/impact/effort/checkStatus + * - checkStatus determines ReadinessCriterionResult.status + * - Extras are signals without pillar metadata + * - Pillar/level summaries and achievedLevel are recomputed from criteria + */ +import type { + ReadinessReport, + ReadinessCriterionResult, + ReadinessExtraResult, + ReadinessPillarSummary, + ReadinessLevelSummary, + ReadinessPillar, + ReadinessScope, + ReadinessStatus +} from "../readiness"; + +import type { EngineReport, Signal, Recommendation } from "./types"; + +const PILLAR_NAMES: Record = { + "style-validation": "Style & Validation", + "build-system": "Build System", + testing: "Testing", + documentation: "Documentation", + "dev-environment": "Dev Environment", + "code-quality": "Code Quality", + observability: "Observability", + "security-governance": "Security & Governance", + "ai-tooling": "AI Tooling" +}; + +const LEVEL_NAMES: Record = { + 1: "Functional", + 2: "Documented", + 3: "Standardized", + 4: "Optimized", + 5: "Autonomous" +}; + +type AdapterOptions = { + repoPath: string; + isMonorepo?: boolean; + apps?: Array<{ name: string; path: string }>; + passRateThreshold?: number; +}; + +/** + * Convert an EngineReport to a legacy ReadinessReport. + * + * This is the bridge that lets the new plugin engine power existing + * readiness surfaces without any surface-level changes. + */ +export function engineReportToReadiness( + report: EngineReport, + options: AdapterOptions +): ReadinessReport { + const passRateThreshold = options.passRateThreshold ?? 0.8; + const { criteria, extras } = partitionSignals(report.signals, report.recommendations); + + const pillars = summarizePillars(criteria); + const levels = summarizeLevels(criteria, passRateThreshold); + const achievedLevel = levels + .filter((l) => l.achieved) + .reduce((max, l) => Math.max(max, l.level), 0); + + return { + repoPath: options.repoPath, + generatedAt: new Date().toISOString(), + isMonorepo: options.isMonorepo ?? false, + apps: options.apps ?? [], + pillars, + levels, + achievedLevel, + criteria, + extras, + policies: { + chain: report.pluginChain as string[], + criteriaCount: criteria.length + }, + engine: { + signals: report.signals, + recommendations: report.recommendations, + policyWarnings: report.policyWarnings, + score: report.score, + grade: report.grade + } + }; +} + +/** + * Partition engine signals into legacy criteria results and extra results. + * + * A signal is treated as a "criterion" if its metadata contains `pillar`. + * Everything else is an "extra". + */ +function partitionSignals( + signals: ReadonlyArray, + _recommendations: ReadonlyArray +): { criteria: ReadinessCriterionResult[]; extras: ReadinessExtraResult[] } { + const criteria: ReadinessCriterionResult[] = []; + const extras: ReadinessExtraResult[] = []; + + for (const signal of signals) { + const meta = (signal.metadata ?? {}) as Record; + if (meta.pillar) { + criteria.push(signalToCriterion(signal, meta)); + } else { + extras.push(signalToExtra(signal, meta)); + } + } + + return { criteria, extras }; +} + +function signalToCriterion( + signal: Signal, + meta: Record +): ReadinessCriterionResult { + const checkStatus = meta.checkStatus as string | undefined; + const status: ReadinessStatus = + checkStatus === "pass" ? "pass" : checkStatus === "fail" ? "fail" : "skip"; + + return { + id: signal.id, + title: signal.label, + pillar: meta.pillar as ReadinessPillar, + level: (meta.level as number) ?? 1, + scope: (meta.scope as ReadinessScope) ?? "repo", + impact: (meta.impact as "high" | "medium" | "low") ?? "medium", + effort: (meta.effort as "low" | "medium" | "high") ?? "medium", + status, + reason: signal.reason, + evidence: signal.evidence + }; +} + +function signalToExtra(signal: Signal, meta: Record): ReadinessExtraResult { + const checkStatus = meta.checkStatus as string | undefined; + const status: ReadinessStatus = + checkStatus === "pass" ? "pass" : checkStatus === "fail" ? "fail" : "skip"; + return { + id: signal.id, + title: signal.label, + status, + reason: signal.reason + }; +} + +function summarizePillars(criteria: ReadinessCriterionResult[]): ReadinessPillarSummary[] { + return (Object.keys(PILLAR_NAMES) as ReadinessPillar[]).map((pillar) => { + const items = criteria.filter((c) => c.pillar === pillar); + const { passed, total } = countStatus(items); + return { + id: pillar, + name: PILLAR_NAMES[pillar], + passed, + total, + passRate: total ? passed / total : 0 + }; + }); +} + +function summarizeLevels( + criteria: ReadinessCriterionResult[], + passRateThreshold: number +): ReadinessLevelSummary[] { + const summaries: ReadinessLevelSummary[] = []; + for (let level = 1; level <= 5; level++) { + const items = criteria.filter((c) => c.level === level); + const { passed, total } = countStatus(items); + const passRate = total ? passed / total : 0; + summaries.push({ level, name: LEVEL_NAMES[level], passed, total, passRate, achieved: false }); + } + for (const summary of summaries) { + const allPrior = summaries.filter((s) => s.level <= summary.level); + summary.achieved = allPrior.every((s) => s.total > 0 && s.passRate >= passRateThreshold); + } + return summaries; +} + +function countStatus(items: ReadinessCriterionResult[]): { passed: number; total: number } { + const relevant = items.filter((item) => item.status !== "skip"); + const passed = relevant.filter((item) => item.status === "pass").length; + return { passed, total: relevant.length }; +} diff --git a/src/services/policy/compiler.ts b/src/services/policy/compiler.ts new file mode 100644 index 0000000..8f2b060 --- /dev/null +++ b/src/services/policy/compiler.ts @@ -0,0 +1,256 @@ +/** + * Phase E: Declarative-to-Plugin Compiler. + * + * Compiles a declarative PolicyConfig (JSON policy) into a PolicyPlugin adapter + * so that both imperative and declarative policies share the same runtime. + * + * Declarative policies can: + * - disable criteria/extras → mapped to EngineOptions.disabledRuleIds + * - override criterion metadata → mapped to afterDetect hook (SignalPatch.modify) + * - add new criteria → mapped to detectors + recommenders + * - set thresholds → returned alongside the plugin for engine-level use + */ +import type { PolicyConfig, ExtraDefinition } from "../policy"; +import type { ReadinessCriterion, ReadinessContext } from "../readiness"; + +import type { + PolicyPlugin, + Detector, + RecommendationImpact, + Recommender, + Signal, + SignalPatch, + PolicyContext +} from "./types"; + +export type CompilationResult = { + plugin: PolicyPlugin; + /** Criterion/extra IDs to disable at engine level. */ + disabledIds: string[]; + /** Pass-rate threshold from the policy (undefined if not set). */ + passRateThreshold?: number; +}; + +/** + * Compile a declarative PolicyConfig into a PolicyPlugin. + * + * The compiled plugin has trust "safe-declarative" and sourceType "json". + * It cannot contain arbitrary code — only metadata overrides and static checks. + */ +export function compilePolicyConfig( + config: PolicyConfig, + /** Base criteria used for metadata override resolution. */ + _baseCriteria: ReadonlyArray +): CompilationResult { + const disabledIds: string[] = []; + const detectors: Detector[] = []; + const recommenders: Recommender[] = []; + let afterDetect: + | ((signals: ReadonlyArray, ctx: PolicyContext) => Promise) + | undefined; + + // ── Collect disabled IDs ── + if (config.criteria?.disable?.length) { + disabledIds.push(...config.criteria.disable); + } + if (config.extras?.disable?.length) { + disabledIds.push(...config.extras.disable); + } + + // ── Build afterDetect hook for metadata overrides ── + if (config.criteria?.override && Object.keys(config.criteria.override).length > 0) { + const overrides = config.criteria.override; + afterDetect = async (signals) => { + const modifications: SignalPatch["modify"] = []; + for (const [id, changes] of Object.entries(overrides)) { + // Only modify signals that exist + const exists = signals.some((s) => s.id === id); + if (exists) { + // Map criterion metadata overrides to signal label/metadata changes + const signalChanges: Record = {}; + if (changes.title) { + signalChanges.label = changes.title; + } + // Store remaining overrides in metadata + const meta: Record = {}; + if (changes.pillar) meta.pillar = changes.pillar; + if (changes.level !== undefined) meta.level = changes.level; + if (changes.scope) meta.scope = changes.scope; + if (changes.impact) meta.impact = changes.impact; + if (changes.effort) meta.effort = changes.effort; + if (Object.keys(meta).length > 0) { + signalChanges.metadata = meta; + } + if (Object.keys(signalChanges).length > 0) { + modifications.push({ + id, + changes: signalChanges as Partial> + }); + } + } + } + return modifications.length > 0 ? { modify: modifications } : undefined; + }; + } + + // ── Build detectors + recommenders from criteria.add ── + if (config.criteria?.add?.length) { + for (const criterion of config.criteria.add) { + detectors.push(criterionToDetector(criterion)); + recommenders.push(criterionToRecommender(criterion)); + } + } + + // ── Build detectors + recommenders from extras.add ── + if (config.extras?.add?.length) { + for (const extra of config.extras.add) { + detectors.push(extraToDetector(extra)); + recommenders.push(extraToRecommender(extra)); + } + } + + const plugin: PolicyPlugin = { + meta: { + name: config.name, + version: config.version, + sourceType: "json", + trust: "safe-declarative" + }, + ...(detectors.length > 0 ? { detectors } : {}), + ...(afterDetect ? { afterDetect } : {}), + ...(recommenders.length > 0 ? { recommenders } : {}) + }; + + return { + plugin, + disabledIds, + passRateThreshold: config.thresholds?.passRate + }; +} + +function criterionToDetector(criterion: ReadinessCriterion): Detector { + return { + id: criterion.id, + kind: mapScope(criterion.scope), + detect: async (ctx) => { + const readinessCtx = policyCtxToReadinessCtx(ctx); + const result = await criterion.check(readinessCtx); + return { + id: criterion.id, + kind: mapScope(criterion.scope), + status: result.status === "skip" ? "not-detected" : "detected", + label: criterion.title, + evidence: result.evidence, + reason: result.reason, + origin: { addedBy: `compiled:${criterion.id}` }, + metadata: { + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + checkStatus: result.status + } + }; + } + }; +} + +function criterionToRecommender(criterion: ReadinessCriterion): Recommender { + return { + id: `${criterion.id}-rec`, + recommend: async (signals) => { + const signal = signals.find((s) => s.id === criterion.id); + if (!signal) return []; + const checkStatus = (signal.metadata as Record)?.checkStatus; + if (checkStatus !== "fail") return []; + // Read impact from signal metadata at call time so afterDetect overrides + // (e.g. from a declarative policy's override block) are reflected in scoring. + const runtimeImpact = (signal.metadata as Record)?.impact as + | RecommendationImpact + | undefined; + const impact = + runtimeImpact ?? + (criterion.impact === "high" ? "high" : criterion.impact === "medium" ? "medium" : "low"); + return { + id: `${criterion.id}-fix`, + signalId: criterion.id, + impact, + message: signal.reason ?? `Fix: ${criterion.title}`, + origin: { addedBy: `compiled:${criterion.id}` } + }; + } + }; +} + +function extraToDetector(extra: ExtraDefinition): Detector { + return { + id: extra.id, + kind: "custom", + detect: async (ctx) => { + const readinessCtx = policyCtxToReadinessCtx(ctx); + const result = await extra.check(readinessCtx); + return { + id: extra.id, + kind: "custom" as const, + status: "detected" as const, + label: extra.title, + reason: result.reason, + origin: { addedBy: `compiled:${extra.id}` }, + metadata: { checkStatus: result.status } + }; + } + }; +} + +function extraToRecommender(extra: ExtraDefinition): Recommender { + return { + id: `${extra.id}-rec`, + recommend: async (signals) => { + const signal = signals.find((s) => s.id === extra.id); + if (!signal) return []; + const checkStatus = (signal.metadata as Record)?.checkStatus; + if (checkStatus !== "fail") return []; + return { + id: `${extra.id}-fix`, + signalId: extra.id, + impact: "low" as const, + message: signal.reason ?? `Fix: ${extra.title}`, + origin: { addedBy: `compiled:${extra.id}` } + }; + } + }; +} + +function mapScope(scope: string): "file" | "git" | "custom" { + switch (scope) { + case "repo": + return "file"; + case "app": + return "file"; + case "area": + return "file"; + default: + return "custom"; + } +} + +/** + * Bridge from PolicyContext to ReadinessContext. + * Used by compiled detectors to run legacy check functions. + */ +function policyCtxToReadinessCtx(ctx: PolicyContext): ReadinessContext { + return { + repoPath: ctx.repoPath, + analysis: { + path: ctx.repoPath, + isGitRepo: true, + languages: [], + frameworks: [], + isMonorepo: false + }, + apps: [], + rootFiles: ctx.rootFiles, + rootPackageJson: ctx.rootPackageJson + }; +} diff --git a/src/services/policy/engine.ts b/src/services/policy/engine.ts new file mode 100644 index 0000000..d7a600f --- /dev/null +++ b/src/services/policy/engine.ts @@ -0,0 +1,150 @@ +import type { + PolicyPlugin, + PolicyContext, + Signal, + Recommendation, + PolicyWarning, + EngineReport, + PluginStage +} from "./types"; +import { + applySignalPatch, + applyRecommendationPatch, + resolveSupersedes, + calculateScore +} from "./types"; + +export type EngineOptions = { + /** IDs of rules to skip entirely (detectors + recommenders). */ + disabledRuleIds?: Set; +}; + +/** + * Execute a chain of plugins through the deterministic pipeline: + * 1. All detectors (all plugins, source order) + * 2. afterDetect hooks (all plugins, source order) + * 3. beforeRecommend hooks (all plugins, source order) + * 4. All recommenders (all plugins, source order) + * 5. afterRecommend hooks (all plugins, source order) + * 6. Resolve supersedes + * 7. Engine-level scoring + */ +export async function executePlugins( + plugins: PolicyPlugin[], + ctx: PolicyContext, + options?: EngineOptions +): Promise { + const warnings: PolicyWarning[] = []; + const disabledIds = options?.disabledRuleIds ?? new Set(); + + // ── Stage 1: Detect ── + let signals: Signal[] = []; + for (const plugin of plugins) { + if (!plugin.detectors?.length) continue; + for (const detector of plugin.detectors) { + if (disabledIds.has(detector.id)) continue; + try { + const result = await detector.detect(ctx); + const emitted = Array.isArray(result) ? result : [result]; + signals.push(...emitted); + } catch (err) { + const cont = handleError(plugin, err, "detect", ctx, warnings); + if (!cont) break; + } + } + } + + // ── Stage 2: afterDetect hooks ── + // Hook stages (2, 3, 5) do not honor onError abort on the outer plugin loop. + // Each hook is a single call per plugin, so "abort" only applies to inner + // detector/recommender loops in stages 1 and 4. + for (const plugin of plugins) { + if (!plugin.afterDetect) continue; + try { + const patch = await plugin.afterDetect(signals, ctx); + if (patch) { + signals = applySignalPatch(signals, patch, plugin.meta.name); + } + } catch (err) { + handleError(plugin, err, "afterDetect", ctx, warnings); + } + } + + // ── Stage 3: beforeRecommend hooks ── + for (const plugin of plugins) { + if (!plugin.beforeRecommend) continue; + try { + const patch = await plugin.beforeRecommend(signals, ctx); + if (patch) { + signals = applySignalPatch(signals, patch, plugin.meta.name); + } + } catch (err) { + handleError(plugin, err, "beforeRecommend", ctx, warnings); + } + } + + // ── Stage 4: Recommend ── + let recommendations: Recommendation[] = []; + for (const plugin of plugins) { + if (!plugin.recommenders?.length) continue; + for (const recommender of plugin.recommenders) { + if (disabledIds.has(recommender.id)) continue; + try { + const result = await recommender.recommend(signals, ctx); + const emitted = Array.isArray(result) ? result : [result]; + recommendations.push(...emitted); + } catch (err) { + const cont = handleError(plugin, err, "recommend", ctx, warnings); + if (!cont) break; + } + } + } + + // ── Stage 5: afterRecommend hooks ── + for (const plugin of plugins) { + if (!plugin.afterRecommend) continue; + try { + const patch = await plugin.afterRecommend(recommendations, signals, ctx); + if (patch) { + recommendations = applyRecommendationPatch(recommendations, patch, plugin.meta.name); + } + } catch (err) { + handleError(plugin, err, "afterRecommend", ctx, warnings); + } + } + + // ── Stage 6: Resolve supersedes ── + recommendations = resolveSupersedes(recommendations); + + // ── Stage 7: Score ── + const { score, grade } = calculateScore(signals, recommendations); + + return { + signals, + recommendations, + policyWarnings: warnings, + score, + grade, + pluginChain: plugins.map((p) => p.meta.name) + }; +} + +function handleError( + plugin: PolicyPlugin, + err: unknown, + stage: PluginStage, + ctx: PolicyContext, + warnings: PolicyWarning[] +): boolean { + const error = err instanceof Error ? err : new Error(String(err)); + // Always record the warning for audit trail, even on abort + warnings.push({ + pluginName: plugin.meta.name, + stage, + message: error.message + }); + if (plugin.onError) { + return plugin.onError(error, stage, ctx); + } + return true; +} diff --git a/src/services/policy/index.ts b/src/services/policy/index.ts new file mode 100644 index 0000000..4247923 --- /dev/null +++ b/src/services/policy/index.ts @@ -0,0 +1,31 @@ +export type { + Signal, + SignalKind, + SignalStatus, + SignalPatch, + Recommendation, + RecommendationImpact, + RecommendationPatch, + PolicyWarning, + Provenance, + PolicyContext, + Detector, + Recommender, + PolicyPlugin, + PluginMeta, + PluginTrust, + PluginSourceType, + PluginStage, + EngineReport, + Grade +} from "./types"; +export { calculateScore } from "./types"; +export { executePlugins } from "./engine"; +export type { EngineOptions } from "./engine"; +export { compilePolicyConfig } from "./compiler"; +export type { CompilationResult } from "./compiler"; +export { buildBuiltinPlugin, loadPluginChain } from "./loader"; +export type { LoadedChain } from "./loader"; +export { engineReportToReadiness } from "./adapter"; +export { compareShadow, writeShadowLog } from "./shadow"; +export type { ShadowResult, ShadowDiscrepancy } from "./shadow"; diff --git a/src/services/policy/loader.ts b/src/services/policy/loader.ts new file mode 100644 index 0000000..cc01c61 --- /dev/null +++ b/src/services/policy/loader.ts @@ -0,0 +1,152 @@ +/** + * Phase F: Plugin loader and chain composition. + * + * Resolves built-in, local/npm imperative, and compiled JSON plugins + * into an ordered plugin chain. Source/load order determines execution. + * + * Loader responsibilities: + * - Build the built-in plugin from current `buildCriteria`/`buildExtras` + * - Load imperative (.ts/.js) plugins with trust "trusted-code" + * - Load declarative (.json) policies via the DSL compiler (trust "safe-declarative") + * - Aggregate disabledIds across all compiled policies into EngineOptions + * - Return the ready-to-execute plugin chain + engine options + */ +import type { PolicyConfig } from "../policy"; +import { loadPolicy } from "../policy"; +import type { ReadinessCriterion } from "../readiness"; +import { buildCriteria, buildExtras } from "../readiness"; + +import { compilePolicyConfig } from "./compiler"; +import type { CompilationResult } from "./compiler"; +import type { EngineOptions } from "./engine"; +import type { PolicyPlugin } from "./types"; + +export type LoadedChain = { + plugins: PolicyPlugin[]; + options: EngineOptions; + /** Pass-rate threshold (last policy wins). */ + passRateThreshold: number; +}; + +/** + * Build the built-in plugin from the current `buildCriteria`/`buildExtras`. + * Each criterion becomes a detector + recommender pair. + * Extras also become detector + recommender pairs. + */ +export function buildBuiltinPlugin(): { plugin: PolicyPlugin; baseCriteria: ReadinessCriterion[] } { + const baseCriteria = buildCriteria(); + const baseExtras = buildExtras(); + + // Only include repo-scoped criteria — app/area-scoped criteria need + // iteration context that the engine doesn't provide yet. + const repoCriteria = baseCriteria.filter((c) => c.scope === "repo"); + + const compiledCriteria = compilePolicyConfig( + { + name: "__builtin__", + criteria: { add: repoCriteria }, + extras: { add: baseExtras } + }, + [] + ); + + const plugin: PolicyPlugin = { + ...compiledCriteria.plugin, + meta: { + ...compiledCriteria.plugin.meta, + name: "builtin", + sourceType: "builtin", + trust: "trusted-code" + } + }; + + return { plugin, baseCriteria }; +} + +/** + * Load a chain of plugins from policy sources. + * + * @param policySources - Policy file paths or npm specifiers + * @param options - Loading options + * @returns Ready-to-execute plugin chain and engine options + */ +export async function loadPluginChain( + policySources: string[], + options?: { jsonOnly?: boolean } +): Promise { + const { plugin: builtinPlugin, baseCriteria } = buildBuiltinPlugin(); + const plugins: PolicyPlugin[] = [builtinPlugin]; + const allDisabledIds: string[] = []; + let passRateThreshold = 0.8; + + for (const source of policySources) { + const policyConfig: PolicyConfig = await loadPolicy(source, { + jsonOnly: options?.jsonOnly + }); + + // Check if this is a module policy (imperative plugin) with code-level hooks + if (isImperativePlugin(policyConfig)) { + // Module policies: wrap as trusted-code plugin + const { plugin: imperativePlugin, compiled } = wrapImperativePolicy( + policyConfig, + baseCriteria + ); + plugins.push(imperativePlugin); + allDisabledIds.push(...compiled.disabledIds); + if (compiled.passRateThreshold !== undefined) { + passRateThreshold = compiled.passRateThreshold; + } + } else { + // Declarative JSON policies: compile to safe-declarative plugin + const compiled: CompilationResult = compilePolicyConfig(policyConfig, baseCriteria); + plugins.push(compiled.plugin); + allDisabledIds.push(...compiled.disabledIds); + if (compiled.passRateThreshold !== undefined) { + passRateThreshold = compiled.passRateThreshold; + } + } + } + + return { + plugins, + options: { + disabledRuleIds: allDisabledIds.length > 0 ? new Set(allDisabledIds) : undefined + }, + passRateThreshold + }; +} + +/** + * Detect if a PolicyConfig contains imperative code. + * + * Module-sourced policies may contain check functions (criteria.add/extras.add) + * which cannot exist in JSON policies. Any policy with add entries + * is treated as imperative; policies with only disable/override are + * purely declarative regardless of source format. + */ +function isImperativePlugin(config: PolicyConfig): boolean { + const hasAddedCriteria = Boolean(config.criteria?.add?.length); + const hasAddedExtras = Boolean(config.extras?.add?.length); + return hasAddedCriteria || hasAddedExtras; +} + +/** + * Wrap a module-sourced PolicyConfig as a trusted-code plugin. + * Uses the compiler for the heavy lifting, then overrides the trust tier. + * Also propagates disabledIds and passRateThreshold from the module policy. + */ +function wrapImperativePolicy( + config: PolicyConfig, + baseCriteria: ReadonlyArray +): { plugin: PolicyPlugin; compiled: CompilationResult } { + const compiled = compilePolicyConfig(config, baseCriteria); + const plugin: PolicyPlugin = { + ...compiled.plugin, + meta: { + ...compiled.plugin.meta, + sourceType: "module", + trust: "trusted-code" + } + }; + return { plugin, compiled }; +} diff --git a/src/services/policy/shadow.ts b/src/services/policy/shadow.ts new file mode 100644 index 0000000..6cca78e --- /dev/null +++ b/src/services/policy/shadow.ts @@ -0,0 +1,192 @@ +/** + * Phase H: Shadow mode and parity gating. + * + * Runs the new plugin engine alongside the legacy readiness system, + * compares outputs, and logs discrepancies. This lets us validate that + * the new engine produces equivalent results before switching the default. + * + * Key design decisions: + * - Legacy and new engine share the same `ReadinessContext` (no duplicate I/O). + * - Discrepancies are logged to `.primer-cache/shadow-mode.log`, not stderr. + * - The function returns the legacy result by default; callers opt into + * the new engine via `useNewEngine: true`. + */ +import fs from "fs/promises"; +import path from "path"; + +import { validateCachePath } from "../../utils/fs"; +import type { ReadinessReport, ReadinessCriterionResult } from "../readiness"; + +import { engineReportToReadiness } from "./adapter"; +import type { EngineReport } from "./types"; + +export type ShadowResult = { + /** The report that should be used by the caller. */ + report: ReadinessReport; + /** Whether the new engine was used as the primary result source. */ + usedNewEngine: boolean; + /** Discrepancies found between legacy and new engine outputs. */ + discrepancies: ShadowDiscrepancy[]; +}; + +export type ShadowDiscrepancy = { + criterionId: string; + field: string; + legacyValue: unknown; + newValue: unknown; +}; + +/** + * Compare a legacy ReadinessReport with a new-engine EngineReport and + * record discrepancies. + * + * @returns The chosen report and any discrepancies found. + */ +export function compareShadow( + legacyReport: ReadinessReport, + engineReport: EngineReport, + options: { + repoPath: string; + passRateThreshold?: number; + useNewEngine?: boolean; + } +): ShadowResult { + const newReport = engineReportToReadiness(engineReport, { + repoPath: options.repoPath, + isMonorepo: legacyReport.isMonorepo, + apps: legacyReport.apps, + passRateThreshold: options.passRateThreshold + }); + + // Pre-filter legacy criteria to repo-scope only: the engine's built-in plugin only + // runs repo-scoped criteria, so area/app-scoped entries would produce false-positive + // "presence: missing" discrepancies on every comparison. + const legacyRepoCriteria = legacyReport.criteria.filter((c) => c.scope === "repo"); + const discrepancies = diffCriteria(legacyRepoCriteria, newReport.criteria); + + return { + report: options.useNewEngine ? newReport : legacyReport, + usedNewEngine: options.useNewEngine ?? false, + discrepancies + }; +} + +/** + * Append shadow-mode discrepancies to `.primer-cache/shadow-mode.log`. + */ +export async function writeShadowLog( + repoPath: string, + discrepancies: ShadowDiscrepancy[] +): Promise { + if (discrepancies.length === 0) return; + + const lines = [ + `Shadow mode comparison — ${new Date().toISOString()}`, + `Discrepancies: ${discrepancies.length}`, + "", + ...discrepancies.map( + (d) => + `[${JSON.stringify(d.criterionId)}] ${d.field}: legacy=${JSON.stringify(d.legacyValue)} new=${JSON.stringify(d.newValue)}` + ), + "" + ]; + + const cacheDir = path.join(repoPath, ".primer-cache"); + const logPath = validateCachePath(cacheDir, "shadow-mode.log"); + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.appendFile(logPath, lines.join("\n") + "\n"); +} + +/** + * Diff criteria between legacy and new engine outputs. + * Compares id, status, pillar, level, impact, effort, and scope fields. + */ +function diffCriteria( + legacy: ReadinessCriterionResult[], + newCriteria: ReadinessCriterionResult[] +): ShadowDiscrepancy[] { + const discrepancies: ShadowDiscrepancy[] = []; + const newById = new Map(newCriteria.map((c) => [c.id, c])); + + for (const legacyCriterion of legacy) { + const newCriterion = newById.get(legacyCriterion.id); + if (!newCriterion) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "presence", + legacyValue: "exists", + newValue: "missing" + }); + continue; + } + + if (legacyCriterion.status !== newCriterion.status) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "status", + legacyValue: legacyCriterion.status, + newValue: newCriterion.status + }); + } + + if (legacyCriterion.pillar !== newCriterion.pillar) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "pillar", + legacyValue: legacyCriterion.pillar, + newValue: newCriterion.pillar + }); + } + + if (legacyCriterion.level !== newCriterion.level) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "level", + legacyValue: legacyCriterion.level, + newValue: newCriterion.level + }); + } + + if (legacyCriterion.impact !== newCriterion.impact) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "impact", + legacyValue: legacyCriterion.impact, + newValue: newCriterion.impact + }); + } + + if (legacyCriterion.effort !== newCriterion.effort) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "effort", + legacyValue: legacyCriterion.effort, + newValue: newCriterion.effort + }); + } + + if (legacyCriterion.scope !== newCriterion.scope) { + discrepancies.push({ + criterionId: legacyCriterion.id, + field: "scope", + legacyValue: legacyCriterion.scope, + newValue: newCriterion.scope + }); + } + } + + // Check for criteria in new engine not present in legacy + const legacyIds = new Set(legacy.map((c) => c.id)); + for (const newCriterion of newCriteria) { + if (!legacyIds.has(newCriterion.id)) { + discrepancies.push({ + criterionId: newCriterion.id, + field: "presence", + legacyValue: "missing", + newValue: "exists" + }); + } + } + + return discrepancies; +} diff --git a/src/services/policy/types.ts b/src/services/policy/types.ts new file mode 100644 index 0000000..2746655 --- /dev/null +++ b/src/services/policy/types.ts @@ -0,0 +1,343 @@ +// ─── Signal & Recommendation Types ─── +// Core types for the unified plugin policy system. +// Both imperative hook plugins and declarative JSON policies compile to +// the same runtime contract via these types. + +/** Classification of what a signal detects. */ +export type SignalKind = "file" | "setting" | "mcp-server" | "model-config" | "git" | "custom"; + +/** Result status of a signal detection. */ +export type SignalStatus = "detected" | "not-detected" | "error"; + +/** Tracks which plugin created or modified a signal/recommendation. */ +export type Provenance = { + addedBy: string; + modifiedBy?: string[]; +}; + +/** A single detection emitted by a detector. */ +export type Signal = { + id: string; + kind: SignalKind; + status: SignalStatus; + label: string; + evidence?: string[]; + reason?: string; + origin: Provenance; + metadata?: Record; +}; + +/** Impact level for a recommendation. */ +export type RecommendationImpact = "critical" | "high" | "medium" | "low" | "info"; + +/** An actionable recommendation derived from signals. */ +export type Recommendation = { + id: string; + signalId: string; + impact: RecommendationImpact; + message: string; + origin: Provenance; + supersedes?: string[]; + metadata?: Record; +}; + +/** A warning emitted during policy execution (non-fatal). */ +export type PolicyWarning = { + pluginName: string; + stage: PluginStage; + message: string; +}; + +// ─── Patch types for immutable hook returns ─── + +export type SignalPatch = { + add?: Signal[]; + remove?: string[]; + modify?: Array<{ id: string; changes: Partial> }>; +}; + +export type RecommendationPatch = { + add?: Recommendation[]; + remove?: string[]; + modify?: Array<{ id: string; changes: Partial> }>; +}; + +// ─── Plugin lifecycle ─── + +export type PluginStage = + | "detect" + | "afterDetect" + | "beforeRecommend" + | "recommend" + | "afterRecommend"; + +/** Read-only context available to all plugin lifecycle stages. */ +export type PolicyContext = { + repoPath: string; + rootFiles: string[]; + rootPackageJson?: Record; + /** Shared key-value cache scoped to a single engine run. */ + cache: Map; +}; + +/** A detector emits signals about the state of a repository. */ +export type Detector = { + id: string; + kind: SignalKind; + detect: (ctx: PolicyContext) => Promise; +}; + +/** A recommender emits actionable recommendations based on signals. */ +export type Recommender = { + id: string; + recommend: ( + signals: ReadonlyArray, + ctx: PolicyContext + ) => Promise; +}; + +/** Trust tier determines what a plugin is allowed to do. */ +export type PluginTrust = "trusted-code" | "safe-declarative"; + +/** Source type of the plugin. */ +export type PluginSourceType = "module" | "json" | "builtin"; + +/** Metadata describing a plugin. */ +export type PluginMeta = { + name: string; + version?: string; + /** Semver range of engine versions this plugin is compatible with. */ + engine?: string; + /** Capabilities this plugin implements (e.g. "detect", "recommend", "hook"). */ + capabilities?: string[]; + sourceType: PluginSourceType; + trust: PluginTrust; +}; + +/** + * The canonical plugin contract. + * Both imperative module plugins and compiled JSON policies implement this. + * All hook stages are optional — plugins only implement what they need. + */ +export type PolicyPlugin = { + meta: PluginMeta; + + /** Detectors that emit signals about repository state. */ + detectors?: Detector[]; + + /** Called after all detectors run. Returns a patch to mutate signals. */ + afterDetect?: ( + signals: ReadonlyArray, + ctx: PolicyContext + ) => Promise; + + /** Called before recommenders run. Returns a patch to mutate signals. */ + beforeRecommend?: ( + signals: ReadonlyArray, + ctx: PolicyContext + ) => Promise; + + /** Recommenders that emit actionable recommendations. */ + recommenders?: Recommender[]; + + /** Called after all recommenders run. Returns a patch to mutate recommendations. */ + afterRecommend?: ( + recommendations: ReadonlyArray, + signals: ReadonlyArray, + ctx: PolicyContext + ) => Promise; + + /** Per-plugin error handler. Return true to continue remaining detectors/recommenders for this plugin, false to abort them. Does not affect other plugins in the chain. Note: return value is ignored for hook stages (afterDetect, beforeRecommend, afterRecommend); only effective in detect/recommend loops. */ + onError?: (error: Error, stage: PluginStage, ctx: PolicyContext) => boolean; +}; + +// ─── Engine output ─── + +/** Grade label for a readiness score. */ +export type Grade = "A" | "B" | "C" | "D" | "F"; + +/** Complete output from the plugin engine. */ +export type EngineReport = { + readonly signals: ReadonlyArray; + readonly recommendations: ReadonlyArray; + readonly policyWarnings: ReadonlyArray; + readonly score: number; + readonly grade: Grade; + readonly pluginChain: ReadonlyArray; +}; + +// ─── Scoring ─── + +const IMPACT_WEIGHTS: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 0 +}; + +/** + * Engine-level scoring reducer. + * Score = 1 - (weighted unresolved recommendations / max possible weight). + * Max weight per signal is "critical" (5) — a single critical recommendation + * against one signal will produce score 0. This is intentional: critical + * findings are designed to dominate the score. + */ +export function calculateScore( + signals: ReadonlyArray, + recommendations: ReadonlyArray +): { score: number; grade: Grade } { + // Exclude not-detected (skipped/non-applicable) signals from scoring denominator. + // Including them would inflate maxWeight and artificially boost scores for repos + // with many non-applicable criteria. + const activeSignals = signals.filter((s) => s.status !== "not-detected"); + if (activeSignals.length === 0) { + return { score: 1, grade: "A" }; + } + + const maxWeight = activeSignals.length * IMPACT_WEIGHTS.critical; + + const deductions = recommendations.reduce((sum, rec) => sum + IMPACT_WEIGHTS[rec.impact], 0); + + const score = Math.max(0, Math.min(1, 1 - deductions / maxWeight)); + return { score, grade: scoreToGrade(score) }; +} + +function scoreToGrade(score: number): Grade { + if (score >= 0.9) return "A"; + if (score >= 0.8) return "B"; + if (score >= 0.7) return "C"; + if (score >= 0.6) return "D"; + return "F"; +} + +// ─── Patch application ─── + +/** Apply a SignalPatch to a list of signals, recording provenance. */ +export function applySignalPatch( + signals: Signal[], + patch: SignalPatch, + pluginName: string +): Signal[] { + let result = [...signals]; + + if (patch.remove?.length) { + const removeSet = new Set(patch.remove); + result = result.filter((s) => !removeSet.has(s.id)); + } + + if (patch.modify?.length) { + for (const mod of patch.modify) { + const idx = result.findIndex((s) => s.id === mod.id); + if (idx >= 0) { + const existing = result[idx]; + const changes = { ...mod.changes }; + // Deep-merge metadata to avoid wiping existing fields (e.g. checkStatus) + if (changes.metadata && existing.metadata) { + changes.metadata = { ...existing.metadata, ...changes.metadata }; + } + result[idx] = { + ...existing, + ...changes, + origin: { + ...existing.origin, + modifiedBy: [...(existing.origin.modifiedBy ?? []), pluginName] + } + }; + } + } + } + + if (patch.add?.length) { + result.push( + ...patch.add.map((s) => ({ + ...s, + evidence: s.evidence ? [...s.evidence] : undefined, + metadata: s.metadata ? { ...s.metadata } : undefined + })) + ); + } + + return result; +} + +/** Apply a RecommendationPatch to a list of recommendations, recording provenance. */ +export function applyRecommendationPatch( + recommendations: Recommendation[], + patch: RecommendationPatch, + pluginName: string +): Recommendation[] { + let result = [...recommendations]; + + if (patch.remove?.length) { + const removeSet = new Set(patch.remove); + result = result.filter((r) => !removeSet.has(r.id)); + } + + if (patch.modify?.length) { + for (const mod of patch.modify) { + const idx = result.findIndex((r) => r.id === mod.id); + if (idx >= 0) { + const existing = result[idx]; + result[idx] = { + ...existing, + ...mod.changes, + origin: { + ...existing.origin, + modifiedBy: [...(existing.origin.modifiedBy ?? []), pluginName] + } + }; + } + } + } + + if (patch.add?.length) { + result.push( + ...patch.add.map((r) => ({ + ...r, + supersedes: r.supersedes ? [...r.supersedes] : undefined, + metadata: r.metadata ? { ...r.metadata } : undefined + })) + ); + } + + return result; +} + +/** + * Resolve supersedes: remove recommendations that are superseded by others. + * Records provenance on the superseding recommendation. + * Circular supersedes chains result in all involved recommendations being dropped. + */ +export function resolveSupersedes(recommendations: Recommendation[]): Recommendation[] { + const supersededIds = new Set(); + const recIds = new Set(recommendations.map((r) => r.id)); + for (const rec of recommendations) { + if (rec.supersedes?.length) { + for (const id of rec.supersedes) { + // Only supersede IDs that actually exist in the list + if (recIds.has(id)) { + supersededIds.add(id); + } + } + } + } + return recommendations + .filter((r) => !supersededIds.has(r.id)) + .map((r) => { + if (!r.supersedes?.length) return r; + // Record which recommendations were actually superseded + const actuallySuperseded = r.supersedes.filter((id) => supersededIds.has(id)); + if (actuallySuperseded.length === 0) return r; + return { + ...r, + origin: { + ...r.origin, + modifiedBy: [ + ...(r.origin.modifiedBy ?? []), + ...actuallySuperseded.map((id) => `superseded:${id}`) + ] + } + }; + }); +} diff --git a/src/services/readiness.ts b/src/services/readiness.ts new file mode 100644 index 0000000..7cfec63 --- /dev/null +++ b/src/services/readiness.ts @@ -0,0 +1,1228 @@ +import fs from "fs/promises"; +import path from "path"; + +import { fileExists, safeReadDir, readJson } from "../utils/fs"; + +import type { RepoApp, RepoAnalysis, Area } from "./analyzer"; +import { analyzeRepo, sanitizeAreaName, loadPrimerConfig } from "./analyzer"; +import type { ExtraDefinition, PolicyConfig } from "./policy"; +import { loadPolicy, resolveChain } from "./policy"; +import { executePlugins } from "./policy/engine"; +import { loadPluginChain } from "./policy/loader"; +import type { Grade, PolicyContext, PolicyWarning, Recommendation, Signal } from "./policy/types"; + +export type ReadinessPillar = + | "style-validation" + | "build-system" + | "testing" + | "documentation" + | "dev-environment" + | "code-quality" + | "observability" + | "security-governance" + | "ai-tooling"; + +export type PillarGroup = "repo-health" | "ai-setup"; + +export const PILLAR_GROUPS: Record = { + "style-validation": "repo-health", + "build-system": "repo-health", + testing: "repo-health", + documentation: "repo-health", + "dev-environment": "repo-health", + "code-quality": "repo-health", + observability: "repo-health", + "security-governance": "repo-health", + "ai-tooling": "ai-setup" +}; + +export const PILLAR_GROUP_NAMES: Record = { + "repo-health": "Repo Health", + "ai-setup": "AI Setup" +}; + +export function groupPillars( + pillars: ReadinessPillarSummary[] +): Array<{ group: PillarGroup; label: string; pillars: ReadinessPillarSummary[] }> { + const groups: PillarGroup[] = ["repo-health", "ai-setup"]; + return groups.map((group) => ({ + group, + label: PILLAR_GROUP_NAMES[group], + pillars: pillars.filter((p) => PILLAR_GROUPS[p.id] === group) + })); +} + +export type ReadinessScope = "repo" | "app" | "area"; + +export type ReadinessStatus = "pass" | "fail" | "skip"; + +export type ReadinessCriterionResult = { + id: string; + title: string; + pillar: ReadinessPillar; + level: number; + scope: ReadinessScope; + impact: "high" | "medium" | "low"; + effort: "low" | "medium" | "high"; + status: ReadinessStatus; + reason?: string; + evidence?: string[]; + passRate?: number; + appSummary?: { passed: number; total: number }; + appFailures?: string[]; + areaSummary?: { passed: number; total: number }; + areaFailures?: string[]; +}; + +export type ReadinessExtraResult = { + id: string; + title: string; + status: ReadinessStatus; + reason?: string; +}; + +export type ReadinessPillarSummary = { + id: ReadinessPillar; + name: string; + passed: number; + total: number; + passRate: number; +}; + +export type ReadinessLevelSummary = { + level: number; + name: string; + passed: number; + total: number; + passRate: number; + achieved: boolean; +}; + +export type AreaReadinessReport = { + area: Area; + criteria: ReadinessCriterionResult[]; + pillars: ReadinessPillarSummary[]; +}; + +export type ReadinessReport = { + repoPath: string; + generatedAt: string; + isMonorepo: boolean; + apps: Array<{ name: string; path: string }>; + pillars: ReadinessPillarSummary[]; + levels: ReadinessLevelSummary[]; + achievedLevel: number; + criteria: ReadinessCriterionResult[]; + extras: ReadinessExtraResult[]; + areaReports?: AreaReadinessReport[]; + policies?: { chain: string[]; criteriaCount: number }; + /** New plugin engine data (populated when using the unified engine). */ + engine?: { + signals: ReadonlyArray; + recommendations: ReadonlyArray; + policyWarnings: ReadonlyArray; + score: number; + grade: Grade; + }; +}; + +type ReadinessOptions = { + repoPath: string; + includeExtras?: boolean; + perArea?: boolean; + policies?: string[]; + /** Run the plugin engine alongside the legacy path and populate report.engine. */ + shadow?: boolean; +}; + +export type ReadinessContext = { + repoPath: string; + analysis: RepoAnalysis; + apps: RepoApp[]; + rootFiles: string[]; + rootPackageJson?: Record; + areaPath?: string; + areaFiles?: string[]; +}; + +export type ReadinessCriterion = { + id: string; + title: string; + pillar: ReadinessPillar; + level: number; + scope: ReadinessScope; + impact: "high" | "medium" | "low"; + effort: "low" | "medium" | "high"; + check: (context: ReadinessContext, app?: RepoApp, area?: Area) => Promise; +}; + +export type CheckResult = { + status: ReadinessStatus; + reason?: string; + evidence?: string[]; +}; + +export async function runReadinessReport(options: ReadinessOptions): Promise { + const repoPath = options.repoPath; + const analysis = await analyzeRepo(repoPath); + const rootFiles = await safeReadDir(repoPath); + const rootPackageJson = await readJson(path.join(repoPath, "package.json")); + const apps = analysis.apps?.length ? analysis.apps : []; + + const context: ReadinessContext = { + repoPath, + analysis, + apps, + rootFiles, + rootPackageJson + }; + + // ── Policy resolution ── + let policySources = options.policies; + // isConfigSourced tracks whether policies were loaded from config (vs CLI --policy flag). + // Used to restrict config-sourced policies to JSON-only, preventing dynamic import() calls. + let isConfigSourced = false; + if (!policySources?.length) { + // Check primer.config.json for policy config + const primerConfig = await loadPrimerConfig(repoPath); + if (primerConfig?.policies?.length) { + policySources = primerConfig.policies; + isConfigSourced = true; + } + } + + const baseCriteria = buildCriteria(); + const baseExtras = buildExtras(); + let resolvedCriteria: ReadinessCriterion[]; + let resolvedExtras: ExtraDefinition[]; + let passRateThreshold = 0.8; + let policyInfo: { chain: string[]; criteriaCount: number } | undefined; + + if (policySources?.length) { + const policyConfigs: PolicyConfig[] = []; + for (const source of policySources) { + policyConfigs.push(await loadPolicy(source, { jsonOnly: isConfigSourced })); + } + const resolved = resolveChain(baseCriteria, baseExtras, policyConfigs); + resolvedCriteria = resolved.criteria; + resolvedExtras = resolved.extras; + passRateThreshold = resolved.thresholds.passRate; + policyInfo = { chain: resolved.chain, criteriaCount: resolved.criteria.length }; + } else { + resolvedCriteria = baseCriteria; + resolvedExtras = baseExtras; + } + + const criteriaResults: ReadinessCriterionResult[] = []; + + for (const criterion of resolvedCriteria) { + if (criterion.scope === "repo") { + const result = await criterion.check(context); + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: result.status, + reason: result.reason, + evidence: result.evidence + }); + continue; + } + + if (criterion.scope === "area") { + if (!options.perArea) continue; // Exclude area criteria unless --per-area + // Area criteria get a placeholder — populated by per-area loop below + const areas = analysis.areas ?? []; + if (areas.length === 0) continue; // No areas, nothing to aggregate + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: "skip", + reason: "Run with --per-area for area breakdown." + }); + continue; + } + + const appResults = await Promise.all( + apps.map(async (app) => ({ + app, + result: await criterion.check(context, app) + })) + ); + + if (!appResults.length) { + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: "skip", + reason: "No application packages detected." + }); + continue; + } + + const passed = appResults.filter((entry) => entry.result.status === "pass").length; + const total = appResults.length; + const passRate = total ? passed / total : 0; + const status: ReadinessStatus = passRate >= passRateThreshold ? "pass" : "fail"; + const failures = appResults + .filter((entry) => entry.result.status !== "pass") + .map((entry) => entry.app.name); + + criteriaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status, + reason: status === "pass" ? undefined : `Only ${passed}/${total} apps pass this check.`, + passRate, + appSummary: { passed, total }, + appFailures: failures + }); + } + + // Per-area breakdown + let areaReports: AreaReadinessReport[] | undefined; + const areas = analysis.areas ?? []; + + if (options.perArea && areas.length > 0) { + const areaCriteria = resolvedCriteria.filter((c) => c.scope === "area"); + areaReports = []; + + for (const area of areas) { + if (!area.path) continue; + const areaFiles = await safeReadDir(area.path); + const areaContext: ReadinessContext = { + ...context, + areaPath: area.path, + areaFiles + }; + + const areaResults: ReadinessCriterionResult[] = []; + for (const criterion of areaCriteria) { + const result = await criterion.check(areaContext, undefined, area); + areaResults.push({ + id: criterion.id, + title: criterion.title, + pillar: criterion.pillar, + level: criterion.level, + scope: criterion.scope, + impact: criterion.impact, + effort: criterion.effort, + status: result.status, + reason: result.reason, + evidence: result.evidence + }); + } + + const areaPillars = summarizePillars(areaResults); + areaReports.push({ area, criteria: areaResults, pillars: areaPillars }); + } + + // Update aggregate area criteria in main results + for (const criterion of criteriaResults) { + if (criterion.scope !== "area") continue; + const perAreaResults = areaReports + .map((ar) => ar.criteria.find((c) => c.id === criterion.id)) + .filter(Boolean) as ReadinessCriterionResult[]; + if (!perAreaResults.length) continue; + + const passed = perAreaResults.filter((r) => r.status === "pass").length; + const total = perAreaResults.length; + const passRate = total ? passed / total : 0; + criterion.status = passRate >= passRateThreshold ? "pass" : "fail"; + criterion.reason = + criterion.status === "pass" ? undefined : `Only ${passed}/${total} areas pass this check.`; + criterion.passRate = passRate; + criterion.areaSummary = { passed, total }; + criterion.areaFailures = areaReports + .filter((ar) => ar.criteria.find((c) => c.id === criterion.id)?.status !== "pass") + .map((ar) => ar.area.name); + } + } + + // Compute summaries after area aggregation so they reflect final statuses + const pillars = summarizePillars(criteriaResults); + const levels = summarizeLevels(criteriaResults, passRateThreshold); + const achievedLevel = levels + .filter((level) => level.achieved) + .reduce((acc, level) => Math.max(acc, level.level), 0); + + const extras = options.includeExtras === false ? [] : await runExtras(context, resolvedExtras); + + // ── Plugin engine: run shadow comparison when opts.shadow is enabled ── + let engine: ReadinessReport["engine"]; + if (options.shadow) { + const policyCtx: PolicyContext = { + repoPath, + rootFiles, + rootPackageJson, + cache: new Map() + }; + const engineChain = await loadPluginChain(policySources ?? [], { jsonOnly: isConfigSourced }); + const engineReport = await executePlugins(engineChain.plugins, policyCtx, engineChain.options); + engine = { + signals: engineReport.signals, + recommendations: engineReport.recommendations, + policyWarnings: engineReport.policyWarnings, + score: engineReport.score, + grade: engineReport.grade + }; + } + + return { + repoPath, + generatedAt: new Date().toISOString(), + isMonorepo: analysis.isMonorepo ?? false, + apps: apps.map((app) => ({ name: app.name, path: app.path })), + pillars, + levels, + achievedLevel, + criteria: criteriaResults, + extras, + areaReports, + policies: policyInfo, + engine + }; +} + +export function buildCriteria(): ReadinessCriterion[] { + return [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasLintConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing ESLint/Biome/Prettier configuration.", + evidence: ["eslint.config.js", ".eslintrc", "biome.json", ".prettierrc"] + }; + } + }, + { + id: "typecheck-config", + title: "Type checking configured", + pillar: "style-validation", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasTypecheckConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing type checking config (tsconfig or equivalent).", + evidence: ["tsconfig.json", "pyproject.toml", "mypy.ini"] + }; + } + }, + { + id: "build-script", + title: "Build script present", + pillar: "build-system", + level: 1, + scope: "app", + impact: "high", + effort: "low", + check: async (_context, app) => { + const found = Boolean(app?.scripts?.build); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing build script in package.json." + }; + } + }, + { + id: "ci-config", + title: "CI workflow configured", + pillar: "build-system", + level: 2, + scope: "repo", + impact: "high", + effort: "medium", + check: async (context) => { + const found = await hasGithubWorkflows(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing .github/workflows CI configuration.", + evidence: [".github/workflows"] + }; + } + }, + { + id: "test-script", + title: "Test script present", + pillar: "testing", + level: 1, + scope: "app", + impact: "high", + effort: "low", + check: async (_context, app) => { + const found = Boolean(app?.scripts?.test); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing test script in package.json." + }; + } + }, + { + id: "readme", + title: "README present", + pillar: "documentation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasReadme(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing README documentation.", + evidence: ["README.md"] + }; + } + }, + { + id: "contributing", + title: "CONTRIBUTING guide present", + pillar: "documentation", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, "CONTRIBUTING.md")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing CONTRIBUTING.md for contributor workflows." + }; + } + }, + { + id: "lockfile", + title: "Lockfile present", + pillar: "dev-environment", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = hasAnyFile(context.rootFiles, [ + "pnpm-lock.yaml", + "yarn.lock", + "package-lock.json", + "bun.lockb" + ]); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing package manager lockfile." + }; + } + }, + { + id: "env-example", + title: "Environment example present", + pillar: "dev-environment", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = hasAnyFile(context.rootFiles, [".env.example", ".env.sample"]); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing .env.example or .env.sample for setup guidance." + }; + } + }, + { + id: "format-config", + title: "Formatter configured", + pillar: "code-quality", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasFormatterConfig(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing Prettier/Biome formatting config." + }; + } + }, + { + id: "codeowners", + title: "CODEOWNERS present", + pillar: "security-governance", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasCodeowners(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing CODEOWNERS file." + }; + } + }, + { + id: "license", + title: "LICENSE present", + pillar: "security-governance", + level: 1, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => { + const found = await hasLicense(context.repoPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing LICENSE file." + }; + } + }, + { + id: "security-policy", + title: "Security policy present", + pillar: "security-governance", + level: 3, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, "SECURITY.md")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing SECURITY.md policy." + }; + } + }, + { + id: "dependabot", + title: "Dependabot configured", + pillar: "security-governance", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await fileExists(path.join(context.repoPath, ".github", "dependabot.yml")); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing .github/dependabot.yml configuration." + }; + } + }, + { + id: "observability", + title: "Observability tooling present", + pillar: "observability", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const deps = await readAllDependencies(context); + const has = deps.some((dep) => + ["@opentelemetry/api", "@opentelemetry/sdk", "pino", "winston", "bunyan"].includes(dep) + ); + return { + status: has ? "pass" : "fail", + reason: "No observability dependencies detected (OpenTelemetry/logging)." + }; + } + }, + { + id: "custom-instructions", + title: "Custom AI instructions or agent guidance", + pillar: "ai-tooling", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const rootFound = await hasCustomInstructions(context.repoPath); + if (rootFound.length === 0) { + return { + status: "fail", + reason: + "Missing custom AI instructions (e.g. copilot-instructions.md, CLAUDE.md, AGENTS.md, .cursorrules).", + evidence: [ + "copilot-instructions.md", + "CLAUDE.md", + "AGENTS.md", + ".cursorrules", + ".github/copilot-instructions.md" + ] + }; + } + + // Check for file-based instructions (.github/instructions/*.instructions.md) + const fileBasedInstructions = await hasFileBasedInstructions(context.repoPath); + const areas = context.analysis.areas ?? []; + + // For monorepos or repos with detected areas, check coverage + if (areas.length > 0) { + if (fileBasedInstructions.length === 0) { + return { + status: "pass", + reason: `Root instructions found, but no file-based instructions for ${areas.length} detected areas. Run \`primer instructions --areas\` to generate.`, + evidence: [...rootFound, ...areas.map((a) => `${a.name}: missing .instructions.md`)] + }; + } + return { + status: "pass", + reason: `Root + ${fileBasedInstructions.length} file-based instruction(s) found.`, + evidence: [...rootFound, ...fileBasedInstructions] + }; + } + + // For monorepos without areas, check per-app instructions (legacy behavior) + if (context.analysis.isMonorepo && context.apps.length > 1) { + const appsMissing: string[] = []; + for (const app of context.apps) { + const appFound = await hasCustomInstructions(app.path); + if (appFound.length === 0) { + appsMissing.push(app.name); + } + } + if (appsMissing.length > 0) { + return { + status: "pass", + reason: `Root instructions found, but ${appsMissing.length}/${context.apps.length} apps missing their own: ${appsMissing.join(", ")}`, + evidence: [ + ...rootFound, + ...appsMissing.map((name) => `${name}: missing app-level instructions`) + ] + }; + } + } + + return { + status: "pass", + evidence: rootFound + }; + } + }, + { + id: "mcp-config", + title: "MCP configuration present", + pillar: "ai-tooling", + level: 2, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => { + const found = await hasMcpConfig(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "Missing MCP (Model Context Protocol) configuration (e.g. .vscode/mcp.json).", + evidence: + found.length > 0 + ? found + : [".vscode/mcp.json", ".vscode/settings.json (mcp section)", "mcp.json"] + }; + } + }, + { + id: "custom-agents", + title: "Custom AI agents configured", + pillar: "ai-tooling", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await hasCustomAgents(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "No custom AI agents configured (e.g. .github/agents/, .copilot/agents/).", + evidence: + found.length > 0 + ? found + : [".github/agents/", ".copilot/agents/", ".github/copilot/agents/"] + }; + } + }, + { + id: "copilot-skills", + title: "Copilot/Claude skills present", + pillar: "ai-tooling", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => { + const found = await hasCopilotSkills(context.repoPath); + return { + status: found.length > 0 ? "pass" : "fail", + reason: "No Copilot or Claude skills found (e.g. .copilot/skills/, .github/skills/).", + evidence: + found.length > 0 ? found : [".copilot/skills/", ".github/skills/", ".claude/skills/"] + }; + } + }, + // ── Area-scoped criteria (only run when areaPath is set) ── + { + id: "area-readme", + title: "Area README present", + pillar: "documentation", + level: 1, + scope: "area", + impact: "medium", + effort: "low", + check: async (context) => { + if (!context.areaPath || !context.areaFiles) { + return { status: "skip", reason: "No area context." }; + } + const found = context.areaFiles.some( + (f) => f.toLowerCase() === "readme.md" || f.toLowerCase() === "readme" + ); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing README in area directory." + }; + } + }, + { + id: "area-build-script", + title: "Area build script present", + pillar: "build-system", + level: 1, + scope: "area", + impact: "high", + effort: "low", + check: async (context, _app, area) => { + if (!context.areaPath || !context.areaFiles) { + return { status: "skip", reason: "No area context." }; + } + // Check area.scripts from enriched Area type + if (area?.scripts?.build) { + return { status: "pass" }; + } + // Fallback: check for package.json with build script in area + const pkgPath = path.join(context.areaPath, "package.json"); + const pkg = await readJson(pkgPath); + const scripts = (pkg?.scripts ?? {}) as Record; + const found = Boolean(scripts.build); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing build script in area." + }; + } + }, + { + id: "area-test-script", + title: "Area test script present", + pillar: "testing", + level: 1, + scope: "area", + impact: "high", + effort: "low", + check: async (context, _app, area) => { + if (!context.areaPath || !context.areaFiles) { + return { status: "skip", reason: "No area context." }; + } + if (area?.scripts?.test) { + return { status: "pass" }; + } + const pkgPath = path.join(context.areaPath, "package.json"); + const pkg = await readJson(pkgPath); + const scripts = (pkg?.scripts ?? {}) as Record; + const found = Boolean(scripts.test); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing test script in area." + }; + } + }, + { + id: "area-instructions", + title: "Area-specific instructions present", + pillar: "ai-tooling", + level: 2, + scope: "area", + impact: "high", + effort: "low", + check: async (context, _app, area) => { + if (!area) { + return { status: "skip", reason: "No area context." }; + } + const sanitized = sanitizeAreaName(area.name); + const instructionPath = path.join( + context.repoPath, + ".github", + "instructions", + `${sanitized}.instructions.md` + ); + const found = await fileExists(instructionPath); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : `Missing .github/instructions/${sanitized}.instructions.md` + }; + } + } + ]; +} + +export function buildExtras(): ExtraDefinition[] { + return [ + { + id: "agents-doc", + title: "AGENTS.md present", + check: async (context) => ({ + status: (await fileExists(path.join(context.repoPath, "AGENTS.md"))) ? "pass" : "fail", + reason: "Missing AGENTS.md to guide coding agents." + }) + }, + { + id: "pr-template", + title: "Pull request template present", + check: async (context) => ({ + status: (await hasPullRequestTemplate(context.repoPath)) ? "pass" : "fail", + reason: "Missing PR template for consistent reviews." + }) + }, + { + id: "pre-commit", + title: "Pre-commit hooks configured", + check: async (context) => ({ + status: (await hasPrecommitConfig(context.repoPath)) ? "pass" : "fail", + reason: "Missing pre-commit or Husky configuration for fast feedback." + }) + }, + { + id: "architecture-doc", + title: "Architecture guide present", + check: async (context) => ({ + status: (await hasArchitectureDoc(context.repoPath)) ? "pass" : "fail", + reason: "Missing architecture documentation." + }) + } + ]; +} + +async function runExtras( + context: ReadinessContext, + extraDefs: ExtraDefinition[] +): Promise { + const results: ReadinessExtraResult[] = []; + for (const def of extraDefs) { + const result = await def.check(context); + results.push({ + id: def.id, + title: def.title, + status: result.status, + reason: result.reason + }); + } + return results; +} + +function summarizePillars(criteria: ReadinessCriterionResult[]): ReadinessPillarSummary[] { + const pillarNames: Record = { + "style-validation": "Style & Validation", + "build-system": "Build System", + testing: "Testing", + documentation: "Documentation", + "dev-environment": "Dev Environment", + "code-quality": "Code Quality", + observability: "Observability", + "security-governance": "Security & Governance", + "ai-tooling": "AI Tooling" + }; + + return (Object.keys(pillarNames) as ReadinessPillar[]).map((pillar) => { + const items = criteria.filter((criterion) => criterion.pillar === pillar); + const { passed, total } = countStatus(items); + return { + id: pillar, + name: pillarNames[pillar], + passed, + total, + passRate: total ? passed / total : 0 + }; + }); +} + +function summarizeLevels( + criteria: ReadinessCriterionResult[], + passRateThreshold = 0.8 +): ReadinessLevelSummary[] { + const levelNames: Record = { + 1: "Functional", + 2: "Documented", + 3: "Standardized", + 4: "Optimized", + 5: "Autonomous" + }; + + const summaries: ReadinessLevelSummary[] = []; + for (let level = 1; level <= 5; level += 1) { + const items = criteria.filter((criterion) => criterion.level === level); + const { passed, total } = countStatus(items); + const passRate = total ? passed / total : 0; + summaries.push({ + level, + name: levelNames[level], + passed, + total, + passRate, + achieved: false + }); + } + + for (const summary of summaries) { + const allPrior = summaries.filter((candidate) => candidate.level <= summary.level); + const achieved = allPrior.every( + (candidate) => candidate.total > 0 && candidate.passRate >= passRateThreshold + ); + summary.achieved = achieved; + } + + return summaries; +} + +function countStatus(items: ReadinessCriterionResult[]): { passed: number; total: number } { + const relevant = items.filter((item) => item.status !== "skip"); + const passed = relevant.filter((item) => item.status === "pass").length; + return { passed, total: relevant.length }; +} + +function hasAnyFile(files: string[], candidates: string[]): boolean { + return candidates.some((candidate) => files.includes(candidate)); +} + +async function hasReadme(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + return files.some( + (file) => file.toLowerCase() === "readme.md" || file.toLowerCase() === "readme" + ); +} + +async function hasLintConfig(repoPath: string): Promise { + return hasAnyFile(await safeReadDir(repoPath), [ + "eslint.config.js", + "eslint.config.mjs", + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "biome.json", + "biome.jsonc", + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + "prettier.config.js", + "prettier.config.cjs" + ]); +} + +async function hasFormatterConfig(repoPath: string): Promise { + return hasAnyFile(await safeReadDir(repoPath), [ + "biome.json", + "biome.jsonc", + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + "prettier.config.js", + "prettier.config.cjs" + ]); +} + +async function hasTypecheckConfig(repoPath: string): Promise { + return hasAnyFile(await safeReadDir(repoPath), [ + "tsconfig.json", + "tsconfig.base.json", + "pyproject.toml", + "mypy.ini" + ]); +} + +async function hasGithubWorkflows(repoPath: string): Promise { + return fileExists(path.join(repoPath, ".github", "workflows")); +} + +async function hasCodeowners(repoPath: string): Promise { + const root = await fileExists(path.join(repoPath, "CODEOWNERS")); + const github = await fileExists(path.join(repoPath, ".github", "CODEOWNERS")); + return root || github; +} + +async function hasLicense(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + return files.some((file) => file.toLowerCase().startsWith("license")); +} + +async function hasPullRequestTemplate(repoPath: string): Promise { + const direct = await fileExists(path.join(repoPath, ".github", "PULL_REQUEST_TEMPLATE.md")); + if (direct) return true; + const dir = path.join(repoPath, ".github", "PULL_REQUEST_TEMPLATE"); + try { + const entries = await fs.readdir(dir); + return entries.some((entry) => entry.toLowerCase().endsWith(".md")); + } catch { + return false; + } +} + +async function hasPrecommitConfig(repoPath: string): Promise { + const precommit = await fileExists(path.join(repoPath, ".pre-commit-config.yaml")); + if (precommit) return true; + return fileExists(path.join(repoPath, ".husky")); +} + +async function hasArchitectureDoc(repoPath: string): Promise { + const files = await safeReadDir(repoPath); + if (files.some((file) => file.toLowerCase() === "architecture.md")) return true; + return fileExists(path.join(repoPath, "docs", "architecture.md")); +} + +async function hasCustomInstructions(repoPath: string): Promise { + const found: string[] = []; + const candidates = [ + ".github/copilot-instructions.md", + "CLAUDE.md", + ".claude/CLAUDE.md", + "AGENTS.md", + ".github/AGENTS.md", + ".cursorrules", + ".cursorignore", + ".windsurfrules", + ".github/instructions.md", + "copilot-instructions.md" + ]; + for (const candidate of candidates) { + if (await fileExists(path.join(repoPath, candidate))) { + found.push(candidate); + } + } + return found; +} + +async function hasFileBasedInstructions(repoPath: string): Promise { + const instructionsDir = path.join(repoPath, ".github", "instructions"); + try { + const entries = await fs.readdir(instructionsDir); + return entries + .filter((e) => e.endsWith(".instructions.md")) + .map((e) => `.github/instructions/${e}`); + } catch { + return []; + } +} + +async function hasMcpConfig(repoPath: string): Promise { + const found: string[] = []; + // Check .vscode/mcp.json + if (await fileExists(path.join(repoPath, ".vscode", "mcp.json"))) { + found.push(".vscode/mcp.json"); + } + // Check root mcp.json + if (await fileExists(path.join(repoPath, "mcp.json"))) { + found.push("mcp.json"); + } + // Check .vscode/settings.json for MCP section + const settings = await readJson(path.join(repoPath, ".vscode", "settings.json")); + if (settings && (settings["mcp"] || settings["github.copilot.chat.mcp.enabled"])) { + found.push(".vscode/settings.json (mcp section)"); + } + // Check .claude/mcp.json + if (await fileExists(path.join(repoPath, ".claude", "mcp.json"))) { + found.push(".claude/mcp.json"); + } + return found; +} + +async function hasCustomAgents(repoPath: string): Promise { + const found: string[] = []; + const agentDirs = [".github/agents", ".copilot/agents", ".github/copilot/agents"]; + for (const dir of agentDirs) { + if (await fileExists(path.join(repoPath, dir))) { + found.push(dir); + } + } + // Check for agent config files + const agentFiles = [".github/copilot-agents.yml", ".github/copilot-agents.yaml"]; + for (const agentFile of agentFiles) { + if (await fileExists(path.join(repoPath, agentFile))) { + found.push(agentFile); + } + } + return found; +} + +async function hasCopilotSkills(repoPath: string): Promise { + const found: string[] = []; + const skillDirs = [ + ".copilot/skills", + ".github/skills", + ".claude/skills", + ".github/copilot/skills" + ]; + for (const dir of skillDirs) { + if (await fileExists(path.join(repoPath, dir))) { + found.push(dir); + } + } + return found; +} + +async function readAllDependencies(context: ReadinessContext): Promise { + const dependencies: string[] = []; + const apps = context.apps.length ? context.apps : []; + for (const app of apps) { + if (!app.packageJsonPath) continue; + const pkg = await readJson(app.packageJsonPath); + const deps = (pkg?.dependencies ?? {}) as Record; + const devDeps = (pkg?.devDependencies ?? {}) as Record; + dependencies.push( + ...Object.keys({ + ...deps, + ...devDeps + }) + ); + } + + if (!apps.length && context.rootPackageJson) { + const rootDeps = (context.rootPackageJson.dependencies ?? {}) as Record; + const rootDevDeps = (context.rootPackageJson.devDependencies ?? {}) as Record; + dependencies.push( + ...Object.keys({ + ...rootDeps, + ...rootDevDeps + }) + ); + } + + return Array.from(new Set(dependencies)); +} diff --git a/src/services/visualReport.ts b/src/services/visualReport.ts new file mode 100644 index 0000000..c77bb5f --- /dev/null +++ b/src/services/visualReport.ts @@ -0,0 +1,932 @@ +import { PILLAR_GROUPS, PILLAR_GROUP_NAMES } from "./readiness"; +import type { AreaReadinessReport, PillarGroup, ReadinessReport } from "./readiness"; + +type VisualReportOptions = { + reports: Array<{ repo: string; report: ReadinessReport; error?: string }>; + title?: string; + generatedAt?: string; +}; + +export function generateVisualReport(options: VisualReportOptions): string { + const { + reports, + title = "AI Readiness Report", + generatedAt = new Date().toISOString() + } = options; + + const successfulReports = reports.filter((r) => !r.error); + const failedReports = reports.filter((r) => r.error); + + const totalRepos = reports.length; + const successfulRepos = successfulReports.length; + const avgLevel = + successfulReports.length > 0 + ? successfulReports.reduce((sum, r) => sum + r.report.achievedLevel, 0) / + successfulReports.length + : 0; + + const pillarStats = calculatePillarStats(successfulReports); + const aiToolingData = calculateAiToolingData(successfulReports); + + return ` + + + + + + ${escapeHtml(title)} + + + +
+
+ +
+

${escapeHtml(title)}

+

Generated ${new Date(generatedAt).toLocaleString()}

+
+ +
+ +
+
+
Repositories
+
${totalRepos}
+
${successfulRepos} analyzed successfully
+
+
+
Avg Maturity
+
${avgLevel.toFixed(1)}
+
${getLevelName(Math.round(avgLevel))}
+
+
+
Success Rate
+
${totalRepos > 0 ? Math.round((successfulRepos / totalRepos) * 100) : 0}%
+
${failedReports.length > 0 ? failedReports.length + " failed" : "All succeeded"}
+
+
+ + ${ + successfulReports.length > 0 + ? ` + ${buildAiToolingHeroHtml(aiToolingData, successfulReports)} + +
+

Pillar Performance

+ ${buildGroupedPillarsHtml(pillarStats)} +
+ +
+

Maturity Model

+
+ ${[1, 2, 3, 4, 5] + .map((level) => { + const count = successfulReports.filter((r) => r.report.achievedLevel === level).length; + return ` +
+
+ ${level} + ${getLevelName(level)} + ${count} repo${count !== 1 ? "s" : ""} +
+
${getLevelDescription(level)}
+
+ `; + }) + .join("")} +
+ +

Distribution

+
+ ${[1, 2, 3, 4, 5] + .map((level) => { + const count = successfulReports.filter((r) => r.report.achievedLevel === level).length; + const percent = + successfulReports.length > 0 ? (count / successfulReports.length) * 100 : 0; + const barHeight = count > 0 ? Math.max(40, percent * 2) : 0; + return ` +
+
${count}
+
+
${level}
${getLevelName(level)}
+
+ `; + }) + .join("")} +
+
+ ` + : "" + } + +
+

Repository Details

+
+ ${reports + .map(({ repo, report, error }) => { + if (error) { + return ` +
+
+
${escapeHtml(repo)}
+ Error +
+
${escapeHtml(error)}
+
+ `; + } + + return ` +
+
+
${escapeHtml(repo)}
+
+ Maturity ${report.achievedLevel}: ${getLevelName(report.achievedLevel)} +
+
+ ${report.isMonorepo ? `
Monorepo · ${report.apps.length} apps
` : ""} +
+ ${report.pillars + .map((pillar) => { + const pillarCriteria = report.criteria.filter((c) => c.pillar === pillar.id); + return ` +
+
+ + ${escapeHtml(pillar.name)} + ${pillar.passed}/${pillar.total} (${Math.round(pillar.passRate * 100)}%) + +
+ ${pillarCriteria + .map( + (c) => ` +
+ ${escapeHtml(c.title)} + ${c.status === "pass" ? "Pass" : c.status === "fail" ? "Fail" : "Skip"} +
+ ` + ) + .join("")} + ${pillarCriteria.length === 0 ? '
No criteria
' : ""} +
+
+
+ `; + }) + .join("")} +
+ ${getTopFixesHtml(report)} + ${buildAreaReportsHtml(report.areaReports)} +
+ `; + }) + .join("")} +
+
+ + ${ + failedReports.length > 0 + ? ` +
+

Failed Repositories

+
+ ${failedReports + .map( + ({ repo, error }) => ` +
+
${escapeHtml(repo)}
+
${escapeHtml(error || "Unknown error")}
+
+ ` + ) + .join("")} +
+
+ ` + : "" + } + + +
+ + +`; +} + +// ── Helper Functions ────────────────────────────────────────────────── + +function calculatePillarStats(reports: Array<{ repo: string; report: ReadinessReport }>): Array<{ + id: string; + name: string; + passed: number; + total: number; + passRate: number; +}> { + const pillarMap = new Map(); + + for (const { report } of reports) { + for (const pillar of report.pillars) { + const existing = pillarMap.get(pillar.id); + if (existing) { + existing.passed += pillar.passed; + existing.total += pillar.total; + } else { + pillarMap.set(pillar.id, { + name: pillar.name, + passed: pillar.passed, + total: pillar.total + }); + } + } + } + + return Array.from(pillarMap.entries()).map(([id, stats]) => ({ + id, + name: stats.name, + passed: stats.passed, + total: stats.total, + passRate: stats.total > 0 ? stats.passed / stats.total : 0 + })); +} + +function getTopFixesHtml(report: ReadinessReport): string { + const failedCriteria = report.criteria + .filter((c) => c.status === "fail") + .sort((a, b) => { + const impactWeight = { high: 3, medium: 2, low: 1 }; + const effortWeight = { low: 1, medium: 2, high: 3 }; + const impactDelta = impactWeight[b.impact] - impactWeight[a.impact]; + if (impactDelta !== 0) return impactDelta; + return effortWeight[a.effort] - effortWeight[b.effort]; + }) + .slice(0, 3); + + if (failedCriteria.length === 0) { + return '
All criteria passing
'; + } + + return ` +
+
Top Fixes Needed
+
    + ${failedCriteria + .map( + (c) => ` +
  • + + ${escapeHtml(c.title)} + ${c.impact} impact, ${c.effort} effort +
  • + ` + ) + .join("")} +
+
+ `; +} + +function getLevelName(level: number): string { + const names: Record = { + 1: "Functional", + 2: "Documented", + 3: "Standardized", + 4: "Optimized", + 5: "Autonomous" + }; + return names[level] || "Unknown"; +} + +function getLevelDescription(level: number): string { + const descriptions: Record = { + 1: "Repo builds, tests run, and basic tooling (linter, lockfile) is in place. AI agents can clone and get started.", + 2: "README, CONTRIBUTING guide, and custom AI instructions exist. Agents understand project context and conventions.", + 3: "CI/CD, security policies, CODEOWNERS, and observability are configured. Agents operate within well-defined guardrails.", + 4: "MCP servers, custom agents, and AI skills are set up. Agents have deep integration with project-specific tools and workflows.", + 5: "Full AI-native development: agents can independently plan, implement, test, and ship changes with minimal human oversight." + }; + return descriptions[level] || ""; +} + +function getProgressClass(passRate: number): string { + if (passRate >= 0.8) return "high"; + if (passRate >= 0.5) return "medium"; + return "low"; +} + +// ── AI Tooling Hero ─────────────────────────────────────────────────── + +type AiToolingCriterionSummary = { + id: string; + title: string; + passCount: number; + totalRepos: number; + status: "pass" | "fail"; + evidence: string[]; + reason: string; +}; + +type AiToolingData = { + criteria: AiToolingCriterionSummary[]; + passed: number; + total: number; + passRate: number; +}; + +function calculateAiToolingData( + reports: Array<{ repo: string; report: ReadinessReport }> +): AiToolingData { + const criterionMap = new Map(); + + for (const { report } of reports) { + const aiCriteria = report.criteria.filter((c) => c.pillar === "ai-tooling"); + for (const c of aiCriteria) { + const existing = criterionMap.get(c.id); + if (existing) { + existing.totalRepos += 1; + if (c.status === "pass") existing.passCount += 1; + if (c.evidence) existing.evidence.push(...c.evidence); + } else { + criterionMap.set(c.id, { + id: c.id, + title: c.title, + passCount: c.status === "pass" ? 1 : 0, + totalRepos: 1, + status: c.status === "pass" ? "pass" : "fail", + evidence: c.evidence ? [...c.evidence] : [], + reason: c.reason || "" + }); + } + } + } + + const criteria = Array.from(criterionMap.values()).map((c) => ({ + ...c, + status: (c.passCount / c.totalRepos >= 0.5 ? "pass" : "fail") as "pass" | "fail", + evidence: [...new Set(c.evidence)] + })); + + const passed = criteria.filter((c) => c.status === "pass").length; + return { + criteria, + passed, + total: criteria.length, + passRate: criteria.length > 0 ? passed / criteria.length : 0 + }; +} + +function getAiScoreClass(passRate: number): string { + if (passRate >= 0.6) return "score-high"; + if (passRate >= 0.3) return "score-medium"; + return "score-low"; +} + +function getAiScoreLabel(passRate: number): string { + if (passRate >= 0.8) return "Excellent"; + if (passRate >= 0.6) return "Good"; + if (passRate >= 0.4) return "Fair"; + if (passRate >= 0.2) return "Getting Started"; + return "Not Started"; +} + +function getAiCriterionIcon(id: string): string { + const icons: Record = { + "custom-instructions": "📝", + "mcp-config": "🔌", + "custom-agents": "🤖", + "copilot-skills": "⚡" + }; + return icons[id] || "🔧"; +} + +function buildAiToolingHeroHtml( + data: AiToolingData, + reports: Array<{ repo: string; report: ReadinessReport }> +): string { + if (data.criteria.length === 0) return ""; + + const pct = Math.round(data.passRate * 100); + const scoreClass = getAiScoreClass(data.passRate); + const scoreLabel = getAiScoreLabel(data.passRate); + + const multiRepo = reports.length > 1; + const perRepoHtml = multiRepo + ? ` +
+
Per Repository
+
+ ${reports + .map(({ repo, report }) => { + const aiPillar = report.pillars.find((p) => p.id === "ai-tooling"); + const repoPct = aiPillar ? Math.round(aiPillar.passRate * 100) : 0; + const repoPass = aiPillar?.passed ?? 0; + const repoTotal = aiPillar?.total ?? 0; + return `
+ ${escapeHtml(repo)} + = 30 ? "var(--color-attention-fg)" : "var(--color-danger-fg)"};">${repoPass}/${repoTotal} (${repoPct}%) +
`; + }) + .join("")} +
+
+ ` + : ""; + + return ` +
+

AI Tooling Readiness

+

How well prepared ${multiRepo ? "your repositories are" : "this repository is"} for AI-assisted development

+ +
+
${pct}%
+
+
${scoreLabel}
+
${data.passed} of ${data.total} AI tooling checks passing${multiRepo ? ` across ${reports.length} repositories` : ""}
+
+
+ +
+ ${data.criteria + .map( + (c) => ` +
+
+ ${c.status === "pass" ? "✓" : "✗"} +
+
+
${getAiCriterionIcon(c.id)} ${escapeHtml(c.title)}
+
${ + c.status === "pass" + ? multiRepo + ? `${c.passCount}/${c.totalRepos} repos` + : "Detected" + : escapeHtml(c.reason) + }
+
+
+ ` + ) + .join("")} +
+ ${perRepoHtml} +
+ `; +} + +function buildGroupedPillarsHtml( + pillarStats: Array<{ id: string; name: string; passed: number; total: number; passRate: number }> +): string { + const groups: PillarGroup[] = ["repo-health", "ai-setup"]; + return groups + .map((group) => { + const pillars = pillarStats.filter( + (p) => PILLAR_GROUPS[p.id as keyof typeof PILLAR_GROUPS] === group + ); + if (pillars.length === 0) return ""; + return ` +

${escapeHtml(PILLAR_GROUP_NAMES[group])}

+
+ ${pillars + .map( + (pillar) => ` +
+
${escapeHtml(pillar.name)}
+
+
+
+
+ ${pillar.passed}/${pillar.total} (${Math.round(pillar.passRate * 100)}%) +
+
+ ` + ) + .join("")} +
+ `; + }) + .join(""); +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return text.replace(/[&<>"']/g, (m) => map[m]); +} + +function buildAreaReportsHtml(areaReports?: AreaReadinessReport[]): string { + if (!areaReports?.length) return ""; + + return ` +
+
Per-Area Breakdown
+
+ ${areaReports + .map((ar) => { + const relevant = ar.criteria.filter((c) => c.status !== "skip"); + const passed = relevant.filter((c) => c.status === "pass").length; + const total = relevant.length; + const pct = total ? Math.round((passed / total) * 100) : 0; + const sourceLabel = ar.area.source === "config" ? "config" : "auto"; + const applyTo = Array.isArray(ar.area.applyTo) + ? ar.area.applyTo.join(", ") + : ar.area.applyTo; + + return ` +
+
+ + ${escapeHtml(ar.area.name)} + + ${sourceLabel} + = 50 ? "var(--color-attention-fg)" : "var(--color-danger-fg)"};">${passed}/${total} (${pct}%) + + +
+
${escapeHtml(applyTo)}
+ ${ar.criteria + .map( + (c) => ` +
+ ${escapeHtml(c.title)} + ${c.status === "pass" ? "Pass" : c.status === "fail" ? "Fail" : "Skip"} +
+ ` + ) + .join("")} +
+
+
+ `; + }) + .join("")} +
+
+ `; +} diff --git a/src/ui/AnimatedBanner.tsx b/src/ui/AnimatedBanner.tsx index f9e5509..ccd99b4 100644 --- a/src/ui/AnimatedBanner.tsx +++ b/src/ui/AnimatedBanner.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from "react"; import { Box, Text } from "ink"; +import React, { useState, useEffect } from "react"; /** * Animation frames for the PRIMER banner fly-in effect. @@ -14,31 +14,31 @@ const FULL_BANNER = [ "██████╔╝██████╔╝██║██╔████╔██║█████╗ ██████╔╝", "██╔═══╝ ██╔══██╗██║██║╚██╔╝██║██╔══╝ ██╔══██╗", "██║ ██║ ██║██║██║ ╚═╝ ██║███████╗██║ ██║", - "╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝", + "╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝" ]; // Animation frames - slide in from right with progressive reveal const generateFrames = (): string[][] => { const frames: string[][] = []; const width = FULL_BANNER[0].length; - + // Frame 0-4: Empty -> sparkles appearing frames.push(["", "", "", "", "", ""]); frames.push(["", "", " ✦", "", "", ""]); frames.push([" ✦", "", " ✦ ✧", "", " ✦", ""]); - + // Frame 5-15: Slide in from right for (let offset = width; offset >= 0; offset -= 4) { - const frame = FULL_BANNER.map(line => { + const frame = FULL_BANNER.map((line) => { if (offset >= line.length) return ""; return " ".repeat(Math.max(0, offset)) + line.slice(0, Math.max(0, line.length - offset)); }); frames.push(frame); } - + // Final frame: Full banner frames.push([...FULL_BANNER]); - + return frames; }; @@ -51,29 +51,31 @@ type ColorRole = "primary" | "accent" | "sparkle"; const THEME_DARK: Record = { primary: "magentaBright", accent: "cyanBright", - sparkle: "yellowBright", + sparkle: "yellowBright" }; const THEME_LIGHT: Record = { primary: "magenta", - accent: "cyan", - sparkle: "yellow", + accent: "cyan", + sparkle: "yellow" }; type AnimatedBannerProps = { onComplete?: () => void; skipAnimation?: boolean; darkMode?: boolean; + maxWidth?: number; }; -export function AnimatedBanner({ - onComplete, +export function AnimatedBanner({ + onComplete, skipAnimation = false, darkMode = true, + maxWidth }: AnimatedBannerProps): React.JSX.Element { const [frameIndex, setFrameIndex] = useState(skipAnimation ? FRAMES.length - 1 : 0); const [isComplete, setIsComplete] = useState(skipAnimation); - + const theme = darkMode ? THEME_DARK : THEME_LIGHT; useEffect(() => { @@ -103,16 +105,18 @@ export function AnimatedBanner({ const currentFrame = FRAMES[frameIndex]; const showSparkles = frameIndex < 3; + const bannerWidth = FULL_BANNER[0].length; + const shouldTruncate = maxWidth != null && maxWidth < bannerWidth; return ( {currentFrame.map((line, i) => ( - - {line || " "} + {(shouldTruncate ? line.slice(0, maxWidth) : line) || " "} ))} @@ -122,14 +126,22 @@ export function AnimatedBanner({ /** * Static banner for use after animation or when animation is disabled. */ -export function StaticBanner({ darkMode = true }: { darkMode?: boolean }): React.JSX.Element { +export function StaticBanner({ + darkMode = true, + maxWidth +}: { + darkMode?: boolean; + maxWidth?: number; +}): React.JSX.Element { const color = darkMode ? "magentaBright" : "magenta"; - + const bannerWidth = FULL_BANNER[0].length; + const shouldTruncate = maxWidth != null && maxWidth < bannerWidth; + return ( {FULL_BANNER.map((line, i) => ( - {line} + {(shouldTruncate ? line.slice(0, maxWidth) : line) || " "} ))} diff --git a/src/ui/BatchReadinessTui.tsx b/src/ui/BatchReadinessTui.tsx new file mode 100644 index 0000000..7818590 --- /dev/null +++ b/src/ui/BatchReadinessTui.tsx @@ -0,0 +1,347 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { Box, Text, useApp, useInput } from "ink"; +import React, { useEffect, useState } from "react"; +import simpleGit from "simple-git"; + +import { buildAuthedUrl, cloneRepo } from "../services/git"; +import type { GitHubOrg, GitHubRepo } from "../services/github"; +import { listUserOrgs, listOrgRepos, listAccessibleRepos } from "../services/github"; +import type { ReadinessReport } from "../services/readiness"; +import { runReadinessReport } from "../services/readiness"; +import { generateVisualReport } from "../services/visualReport"; +import { safeWriteFile, ensureDir, validateCachePath } from "../utils/fs"; + +import { StaticBanner } from "./AnimatedBanner"; + +type Props = { + token: string; + outputPath?: string; + policies?: string[]; +}; + +type Status = + | "loading-orgs" + | "select-orgs" + | "loading-repos" + | "select-repos" + | "confirm" + | "processing" + | "complete" + | "error"; + +type ProcessResult = { + repo: string; + report?: ReadinessReport; + error?: string; +}; + +export function BatchReadinessTui({ token, outputPath, policies }: Props): React.JSX.Element { + const app = useApp(); + const [status, setStatus] = useState("loading-orgs"); + const [message, setMessage] = useState("Fetching organizations..."); + const [errorMessage, setErrorMessage] = useState(""); + + // Data + const [orgs, setOrgs] = useState([]); + const [repos, setRepos] = useState([]); + const [selectedOrgIndices, setSelectedOrgIndices] = useState>(new Set()); + const [selectedRepoIndices, setSelectedRepoIndices] = useState>(new Set()); + const [cursorIndex, setCursorIndex] = useState(0); + + // Processing + const [results, setResults] = useState([]); + const [currentRepoIndex, setCurrentRepoIndex] = useState(0); + const [processingMessage, setProcessingMessage] = useState(""); + + // Load orgs on mount + useEffect(() => { + loadOrgs(); + }, []); + + async function loadOrgs() { + try { + const userOrgs = await listUserOrgs(token); + const allOrgs: GitHubOrg[] = [ + { login: "__personal__", name: "Personal Repositories" }, + ...userOrgs + ]; + setOrgs(allOrgs); + setStatus("select-orgs"); + setMessage("Select organizations (space to toggle, enter to confirm)"); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch organizations"); + } + } + + async function loadRepos() { + setStatus("loading-repos"); + setMessage("Fetching repositories..."); + try { + const selectedOrgs = Array.from(selectedOrgIndices).map((i) => orgs[i]); + let allRepos: GitHubRepo[] = []; + + for (let idx = 0; idx < selectedOrgs.length; idx++) { + const org = selectedOrgs[idx]; + setMessage( + `Fetching repos from ${org.name ?? org.login} (${idx + 1}/${selectedOrgs.length})...` + ); + + if (org.login === "__personal__") { + const personalRepos = await listAccessibleRepos(token); + const userRepos = personalRepos + .filter((r) => !orgs.some((o) => o.login !== "__personal__" && o.login === r.owner)) + .slice(0, 100); + allRepos = [...allRepos, ...userRepos]; + } else { + const orgRepos = await listOrgRepos(token, org.login, 100); + allRepos = [...allRepos, ...orgRepos]; + } + } + + setRepos(allRepos); + setStatus("select-repos"); + setMessage(`Select repositories (${allRepos.length} available)`); + setCursorIndex(0); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch repositories"); + } + } + + async function processRepos() { + setStatus("processing"); + const selectedRepos = Array.from(selectedRepoIndices).map((i) => repos[i]); + const results: ProcessResult[] = []; + const tmpDir = path.join(os.tmpdir(), `primer-batch-readiness-${Date.now()}`); + + try { + await ensureDir(tmpDir); + + for (let i = 0; i < selectedRepos.length; i++) { + const repo = selectedRepos[i]; + setCurrentRepoIndex(i); + setProcessingMessage(`Analyzing ${repo.fullName} (${i + 1}/${selectedRepos.length})`); + + const repoDir = validateCachePath(tmpDir, repo.owner, repo.name); + + try { + // Clone repo + setProcessingMessage(`Cloning ${repo.fullName}...`); + const authedUrl = buildAuthedUrl(repo.cloneUrl, token, "github"); + await cloneRepo(authedUrl, repoDir, { shallow: true }); + // Strip credentials from persisted remote URL + const git = simpleGit(repoDir); + await git.remote(["set-url", "origin", repo.cloneUrl]); + + // Run readiness report + setProcessingMessage(`Running readiness report for ${repo.fullName}...`); + const report = await runReadinessReport({ repoPath: repoDir, policies }); + + results.push({ + repo: repo.fullName, + report + }); + } catch (error) { + results.push({ + repo: repo.fullName, + error: error instanceof Error ? error.message : "Unknown error" + }); + } + } + + setResults(results); + + // Generate visual report + const html = generateVisualReport({ + reports: results + .filter((r) => r.report || r.error) + .map((r) => ({ + repo: r.repo, + report: r.report ?? { + repoPath: r.repo, + generatedAt: new Date().toISOString(), + isMonorepo: false, + apps: [], + pillars: [], + levels: [], + achievedLevel: 0, + criteria: [], + extras: [] + }, + error: r.error + })), + title: "Batch AI Readiness Report", + generatedAt: new Date().toISOString() + }); + + const finalOutputPath = outputPath ?? path.join(process.cwd(), "batch-readiness-report.html"); + const { wrote, reason } = await safeWriteFile(finalOutputPath, html, true); + if (!wrote) throw new Error(reason === "symlink" ? "Path is a symlink" : "Write failed"); + + setStatus("complete"); + setMessage(`Report generated: ${finalOutputPath}`); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to process repositories"); + } finally { + // Clean up temp directory + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + + useInput((input, key) => { + if (key.escape || input.toLowerCase() === "q") { + app.exit(); + return; + } + + if (status === "select-orgs") { + if (key.upArrow) { + setCursorIndex(Math.max(0, cursorIndex - 1)); + } else if (key.downArrow) { + setCursorIndex(Math.min(orgs.length - 1, cursorIndex + 1)); + } else if (input === " ") { + const newSelected = new Set(selectedOrgIndices); + if (newSelected.has(cursorIndex)) { + newSelected.delete(cursorIndex); + } else { + newSelected.add(cursorIndex); + } + setSelectedOrgIndices(newSelected); + } else if (key.return) { + if (selectedOrgIndices.size === 0) { + setMessage("Please select at least one organization"); + return; + } + loadRepos().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Failed to load repos"); + }); + } else if (input.toLowerCase() === "a") { + setSelectedOrgIndices(new Set(orgs.map((_, i) => i))); + } + } + + if (status === "select-repos") { + if (key.upArrow) { + setCursorIndex(Math.max(0, cursorIndex - 1)); + } else if (key.downArrow) { + setCursorIndex(Math.min(repos.length - 1, cursorIndex + 1)); + } else if (input === " ") { + const newSelected = new Set(selectedRepoIndices); + if (newSelected.has(cursorIndex)) { + newSelected.delete(cursorIndex); + } else { + newSelected.add(cursorIndex); + } + setSelectedRepoIndices(newSelected); + } else if (key.return) { + if (selectedRepoIndices.size === 0) { + setMessage("Please select at least one repository"); + return; + } + setStatus("confirm"); + setMessage(`Process ${selectedRepoIndices.size} repositories? (y/n)`); + } else if (input.toLowerCase() === "a") { + setSelectedRepoIndices(new Set(repos.map((_, i) => i))); + } + } + + if (status === "confirm") { + if (input.toLowerCase() === "y") { + processRepos().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Processing failed"); + }); + } else if (input.toLowerCase() === "n") { + setStatus("select-repos"); + setMessage(`Select repositories (${repos.length} available)`); + } + } + + if (status === "complete" || status === "error") { + app.exit(); + } + }); + + return ( + + + + Batch Readiness Report + + + + {message} + + + {status === "error" && errorMessage && ( + + {errorMessage} + + )} + + {status === "select-orgs" && ( + + Organizations: + {orgs.slice(0, 20).map((org, i) => ( + + {i === cursorIndex ? ">" : " "} [{selectedOrgIndices.has(i) ? "●" : " "}]{" "} + {org.name ?? org.login} + + ))} + + [Space] toggle • [A] select all • [Enter] confirm • [Q] quit + + + )} + + {status === "select-repos" && ( + + Repositories ({repos.length}): + {repos + .slice(Math.max(0, cursorIndex - 10), Math.min(repos.length, cursorIndex + 10)) + .map((repo, i) => { + const actualIndex = Math.max(0, cursorIndex - 10) + i; + return ( + + {actualIndex === cursorIndex ? ">" : " "} [ + {selectedRepoIndices.has(actualIndex) ? "●" : " "}] {repo.fullName} + + ); + })} + + [Space] toggle • [A] select all • [Enter] confirm • [Q] quit + + + )} + + {status === "processing" && ( + + Processing repositories... + {processingMessage} + + Progress: {currentRepoIndex + 1}/{Array.from(selectedRepoIndices).length} + + + )} + + {status === "complete" && ( + + ✓ Complete! + Total repositories: {results.length} + Successful: {results.filter((r) => !r.error).length} + Failed: {results.filter((r) => r.error).length} + + )} + + ); +} diff --git a/src/ui/BatchTui.tsx b/src/ui/BatchTui.tsx index 2a13121..f67e4a5 100644 --- a/src/ui/BatchTui.tsx +++ b/src/ui/BatchTui.tsx @@ -1,19 +1,17 @@ -import React, { useEffect, useState } from "react"; import { Box, Text, useApp, useInput } from "ink"; -import path from "path"; -import fs from "fs/promises"; +import React, { useEffect, useState } from "react"; + +import { processGitHubRepo } from "../services/batch"; +import type { ProcessResult } from "../services/batch"; +import type { GitHubOrg, GitHubRepo } from "../services/github"; import { - GitHubOrg, - GitHubRepo, listUserOrgs, listOrgRepos, - createPullRequest, listAccessibleRepos, checkReposForInstructions } from "../services/github"; -import { cloneRepo, checkoutBranch, commitAll, pushBranch, isGitRepo, CloneOptions } from "../services/git"; -import { generateCopilotInstructions } from "../services/instructions"; -import { ensureDir } from "../utils/fs"; +import { safeWriteFile } from "../utils/fs"; + import { StaticBanner } from "./AnimatedBanner"; type Props = { @@ -31,13 +29,6 @@ type Status = | "complete" | "error"; -type ProcessResult = { - repo: string; - success: boolean; - prUrl?: string; - error?: string; -}; - export function BatchTui({ token, outputPath }: Props): React.JSX.Element { const app = useApp(); const [status, setStatus] = useState("loading-orgs"); @@ -53,7 +44,6 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { // Processing const [results, setResults] = useState([]); - const [currentRepoIndex, setCurrentRepoIndex] = useState(0); const [processingMessage, setProcessingMessage] = useState(""); // Load orgs on mount @@ -82,19 +72,21 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { setStatus("loading-repos"); setMessage("Fetching repositories..."); try { - const selectedOrgs = Array.from(selectedOrgIndices).map(i => orgs[i]); + const selectedOrgs = Array.from(selectedOrgIndices).map((i) => orgs[i]); let allRepos: GitHubRepo[] = []; for (let idx = 0; idx < selectedOrgs.length; idx++) { const org = selectedOrgs[idx]; - setMessage(`Fetching repos from ${org.name ?? org.login} (${idx + 1}/${selectedOrgs.length})...`); - + setMessage( + `Fetching repos from ${org.name ?? org.login} (${idx + 1}/${selectedOrgs.length})...` + ); + if (org.login === "__personal__") { // Fetch personal repos (limited to 100 most recently pushed) const personalRepos = await listAccessibleRepos(token); // Filter to only repos owned by the user (not org repos) const userRepos = personalRepos - .filter(r => !orgs.some(o => o.login !== "__personal__" && o.login === r.owner)) + .filter((r) => !orgs.some((o) => o.login !== "__personal__" && o.login === r.owner)) .slice(0, 100); allRepos = [...allRepos, ...userRepos]; } else { @@ -106,7 +98,7 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { // Deduplicate by fullName const seen = new Set(); - const uniqueRepos = allRepos.filter(r => { + const uniqueRepos = allRepos.filter((r) => { if (seen.has(r.fullName)) return false; seen.add(r.fullName); return true; @@ -115,9 +107,10 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { // Check which repos already have instructions setMessage(`Checking ${uniqueRepos.length} repos for existing instructions...`); const reposWithStatus = await checkReposForInstructions( - token, + token, uniqueRepos, - (checked, total) => setMessage(`Checking for existing instructions (${checked}/${total})...`) + (checked, total) => + setMessage(`Checking for existing instructions (${checked}/${total})...`) ); // Sort: repos without instructions first @@ -126,14 +119,16 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { return a.hasInstructions ? 1 : -1; }); - const withInstructions = reposWithStatus.filter(r => r.hasInstructions).length; + const withInstructions = reposWithStatus.filter((r) => r.hasInstructions).length; const withoutInstructions = reposWithStatus.length - withInstructions; setRepos(reposWithStatus); setCursorIndex(0); setSelectedRepoIndices(new Set()); setStatus("select-repos"); - setMessage(`Found ${reposWithStatus.length} repos (${withoutInstructions} need instructions, ${withInstructions} already have them)`); + setMessage( + `Found ${reposWithStatus.length} repos (${withoutInstructions} need instructions, ${withInstructions} already have them)` + ); } catch (error) { setStatus("error"); setErrorMessage(error instanceof Error ? error.message : "Failed to fetch repositories"); @@ -141,98 +136,34 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { } async function processRepos() { - const selectedRepos = Array.from(selectedRepoIndices).map(i => repos[i]); + const selectedRepos = Array.from(selectedRepoIndices).map((i) => repos[i]); setStatus("processing"); - setCurrentRepoIndex(0); setResults([]); + const localResults: ProcessResult[] = []; + for (let i = 0; i < selectedRepos.length; i++) { const repo = selectedRepos[i]; - setCurrentRepoIndex(i); - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Cloning...`); - - try { - // Clone - const cacheRoot = path.join(process.cwd(), ".primer-cache"); - const repoPath = path.join(cacheRoot, repo.owner, repo.name); - await ensureDir(repoPath); - - if (!(await isGitRepo(repoPath))) { - // Add auth to clone URL (strip trailing slashes first) - const cleanUrl = repo.cloneUrl.replace(/\/+$/, ""); - const authedUrl = cleanUrl.replace("https://", `https://x-access-token:${token}@`); - await cloneRepo(authedUrl, repoPath, { - shallow: true, - timeoutMs: 120000, // 2 minute timeout for clone - onProgress: (stage, progress) => { - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Cloning (${stage} ${progress}%)...`); - } - }); - } - - // Branch - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Creating branch...`); - const branch = "primer/add-instructions"; - await checkoutBranch(repoPath, branch); - - // Generate instructions with timeout - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Generating instructions...`); - - const timeoutMs = 120000; // 2 minute timeout per repo - const instructionsPromise = generateCopilotInstructions({ - repoPath, - model: "gpt-4.1", - onProgress: (msg) => { - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: ${msg}`); - } - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Generation timed out after 2 minutes")), timeoutMs); - }); - - const instructions = await Promise.race([instructionsPromise, timeoutPromise]); - - if (!instructions.trim()) { - throw new Error("Generated instructions were empty"); + setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Starting...`); + + const result = await processGitHubRepo({ + repo, + token, + progress: { + update: (msg) => setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${msg}`), + succeed: (msg) => setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ✓ ${msg}`), + fail: (msg) => setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ✗ ${msg}`), + done: () => {} } + }); - // Write instructions - const instructionsPath = path.join(repoPath, ".github", "copilot-instructions.md"); - await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); - await fs.writeFile(instructionsPath, instructions, "utf8"); - - // Commit - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Committing...`); - await commitAll(repoPath, "chore: add copilot instructions via Primer"); - - // Push - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Pushing...`); - await pushBranch(repoPath, branch, token); - - // Create PR - setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${repo.fullName}: Creating PR...`); - const prUrl = await createPullRequest({ - token, - owner: repo.owner, - repo: repo.name, - title: "🤖 Add Copilot instructions via Primer", - body: buildPrBody(), - head: branch, - base: repo.defaultBranch - }); - - setResults(prev => [...prev, { repo: repo.fullName, success: true, prUrl }]); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error"; - setResults(prev => [...prev, { repo: repo.fullName, success: false, error: errorMsg }]); - } + localResults.push(result); + setResults([...localResults]); } // Write results if output path specified if (outputPath) { - const finalResults = [...results]; - await fs.writeFile(outputPath, JSON.stringify(finalResults, null, 2), "utf8"); + await safeWriteFile(outputPath, JSON.stringify(localResults, null, 2), true); } setStatus("complete"); @@ -248,11 +179,11 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { if (status === "select-orgs") { const items = orgs; if (key.upArrow) { - setCursorIndex(prev => Math.max(0, prev - 1)); + setCursorIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setCursorIndex(prev => Math.min(items.length - 1, prev + 1)); + setCursorIndex((prev) => Math.min(items.length - 1, prev + 1)); } else if (input === " ") { - setSelectedOrgIndices(prev => { + setSelectedOrgIndices((prev) => { const next = new Set(prev); if (next.has(cursorIndex)) { next.delete(cursorIndex); @@ -262,18 +193,21 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { return next; }); } else if (key.return && selectedOrgIndices.size > 0) { - loadRepos(); + loadRepos().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Failed to load repos"); + }); } } if (status === "select-repos") { const items = repos; if (key.upArrow) { - setCursorIndex(prev => Math.max(0, prev - 1)); + setCursorIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setCursorIndex(prev => Math.min(items.length - 1, prev + 1)); + setCursorIndex((prev) => Math.min(items.length - 1, prev + 1)); } else if (input === " ") { - setSelectedRepoIndices(prev => { + setSelectedRepoIndices((prev) => { const next = new Set(prev); if (next.has(cursorIndex)) { next.delete(cursorIndex); @@ -291,13 +225,18 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { setSelectedRepoIndices(new Set(indicesWithoutInstructions)); } else if (key.return && selectedRepoIndices.size > 0) { setStatus("confirm"); - setMessage(`Ready to process ${selectedRepoIndices.size} repositories. Press Y to confirm, N to go back.`); + setMessage( + `Ready to process ${selectedRepoIndices.size} repositories. Press Y to confirm, N to go back.` + ); } } if (status === "confirm") { if (input.toLowerCase() === "y") { - processRepos(); + processRepos().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Processing failed"); + }); } else if (input.toLowerCase() === "n") { setStatus("select-repos"); setMessage("Select repos (space to toggle, enter to confirm)"); @@ -366,7 +305,9 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { {isCursor ? "❯ " : " "} {isSelected ? "◉" : "○"} - {repo.hasInstructions ? "✓" : "✗"} + + {repo.hasInstructions ? "✓" : "✗"}{" "} + {repo.fullName} {repo.isPrivate && (private)} @@ -407,7 +348,8 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { {status === "complete" && ( - ✓ Batch complete: {results.filter(r => r.success).length} succeeded, {results.filter(r => !r.success).length} failed + ✓ Batch complete: {results.filter((r) => r.success).length} succeeded,{" "} + {results.filter((r) => !r.success).length} failed {results.map((r) => ( @@ -423,44 +365,16 @@ export function BatchTui({ token, outputPath }: Props): React.JSX.Element { {status === "select-orgs" && ( - Keys: [Space] Toggle [Enter] Confirm [Q] Quit + Keys: [Space] Toggle [Enter] Confirm [Q] Quit )} {status === "select-repos" && ( - Keys: [Space] Toggle [A] Select Missing [Enter] Confirm [Q] Quit + Keys: [Space] Toggle [A] Select Missing [Enter] Confirm [Q] Quit )} {status === "confirm" && ( - Keys: [Y] Yes, proceed [N] Go back [Q] Quit - )} - {(status === "complete" || status === "error") && ( - Keys: [Q] Quit + Keys: [Y] Yes, proceed [N] Go back [Q] Quit )} + {(status === "complete" || status === "error") && Keys: [Q] Quit} ); } - -function buildPrBody(): string { - return [ - "## 🤖 Copilot Instructions Added", - "", - "This PR adds a `.github/copilot-instructions.md` file to help GitHub Copilot understand this codebase better.", - "", - "### What's Included", - "", - "The instructions file contains:", - "- Project overview and architecture", - "- Tech stack and conventions", - "- Build/test commands", - "- Key directories and files", - "", - "### Benefits", - "", - "With these instructions, Copilot will:", - "- Generate more contextually-aware code suggestions", - "- Follow project-specific patterns and conventions", - "- Understand the codebase structure", - "", - "---", - "*Generated by [Primer](https://github.com/pierceboggan/primer) - Prime your repos for AI*" - ].join("\n"); -} diff --git a/src/ui/BatchTuiAzure.tsx b/src/ui/BatchTuiAzure.tsx new file mode 100644 index 0000000..356c846 --- /dev/null +++ b/src/ui/BatchTuiAzure.tsx @@ -0,0 +1,440 @@ +import { Box, Text, useApp, useInput } from "ink"; +import React, { useEffect, useState } from "react"; + +import type { AzureDevOpsOrg, AzureDevOpsProject, AzureDevOpsRepo } from "../services/azureDevops"; +import { + listOrganizations, + listProjects, + listRepos, + checkReposForInstructions +} from "../services/azureDevops"; +import { processAzureRepo } from "../services/batch"; +import type { ProcessResult } from "../services/batch"; +import { safeWriteFile } from "../utils/fs"; + +import { StaticBanner } from "./AnimatedBanner"; + +type Props = { + token: string; + outputPath?: string; +}; + +type Status = + | "loading-orgs" + | "select-orgs" + | "loading-projects" + | "select-projects" + | "loading-repos" + | "select-repos" + | "confirm" + | "processing" + | "complete" + | "error"; + +export function BatchTuiAzure({ token, outputPath }: Props): React.JSX.Element { + const app = useApp(); + const [status, setStatus] = useState("loading-orgs"); + const [message, setMessage] = useState("Fetching organizations..."); + const [errorMessage, setErrorMessage] = useState(""); + + const [orgs, setOrgs] = useState([]); + const [projects, setProjects] = useState([]); + const [repos, setRepos] = useState([]); + const [selectedOrgIndices, setSelectedOrgIndices] = useState>(new Set()); + const [selectedProjectIndices, setSelectedProjectIndices] = useState>(new Set()); + const [selectedRepoIndices, setSelectedRepoIndices] = useState>(new Set()); + const [cursorIndex, setCursorIndex] = useState(0); + + const [results, setResults] = useState([]); + const [processingMessage, setProcessingMessage] = useState(""); + + useEffect(() => { + loadOrgs(); + }, []); + + async function loadOrgs() { + try { + const userOrgs = await listOrganizations(token); + setOrgs(userOrgs); + setStatus("select-orgs"); + setMessage("Select organizations (space to toggle, enter to confirm)"); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch organizations"); + } + } + + async function loadProjects() { + setStatus("loading-projects"); + setMessage("Fetching projects..."); + + try { + const selectedOrgs = Array.from(selectedOrgIndices).map((i) => orgs[i]); + let allProjects: AzureDevOpsProject[] = []; + + for (let idx = 0; idx < selectedOrgs.length; idx++) { + const org = selectedOrgs[idx]; + setMessage(`Fetching projects from ${org.name} (${idx + 1}/${selectedOrgs.length})...`); + const orgProjects = await listProjects(token, org.name); + allProjects = [...allProjects, ...orgProjects]; + } + + setProjects(allProjects); + setCursorIndex(0); + setSelectedProjectIndices(new Set()); + setStatus("select-projects"); + setMessage("Select projects (space to toggle, enter to confirm)"); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch projects"); + } + } + + async function loadRepos() { + setStatus("loading-repos"); + setMessage("Fetching repositories..."); + + try { + const selectedProjects = Array.from(selectedProjectIndices).map((i) => projects[i]); + let allRepos: AzureDevOpsRepo[] = []; + + for (let idx = 0; idx < selectedProjects.length; idx++) { + const project = selectedProjects[idx]; + setMessage( + `Fetching repos from ${project.organization}/${project.name} (${idx + 1}/${selectedProjects.length})...` + ); + const projectRepos = await listRepos(token, project.organization, project.name); + allRepos = [...allRepos, ...projectRepos]; + } + + const seen = new Set(); + const uniqueRepos = allRepos.filter((repo) => { + const key = `${repo.organization}/${repo.project}/${repo.name}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + setMessage(`Checking ${uniqueRepos.length} repos for existing instructions...`); + const reposWithStatus = await checkReposForInstructions( + token, + uniqueRepos, + (checked, total) => + setMessage(`Checking for existing instructions (${checked}/${total})...`) + ); + + reposWithStatus.sort((a, b) => { + if (a.hasInstructions === b.hasInstructions) return 0; + return a.hasInstructions ? 1 : -1; + }); + + const withInstructions = reposWithStatus.filter((r) => r.hasInstructions).length; + const withoutInstructions = reposWithStatus.length - withInstructions; + + setRepos(reposWithStatus); + setCursorIndex(0); + setSelectedRepoIndices(new Set()); + setStatus("select-repos"); + setMessage( + `Found ${reposWithStatus.length} repos (${withoutInstructions} need instructions, ${withInstructions} already have them)` + ); + } catch (error) { + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : "Failed to fetch repositories"); + } + } + + async function processRepos() { + const selectedRepos = Array.from(selectedRepoIndices).map((i) => repos[i]); + setStatus("processing"); + setResults([]); + + const localResults: ProcessResult[] = []; + + for (let i = 0; i < selectedRepos.length; i++) { + const repo = selectedRepos[i]; + const label = `${repo.organization}/${repo.project}/${repo.name}`; + setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${label}: Starting...`); + + const result = await processAzureRepo({ + repo, + token, + progress: { + update: (msg) => setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ${msg}`), + succeed: (msg) => setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ✓ ${msg}`), + fail: (msg) => setProcessingMessage(`[${i + 1}/${selectedRepos.length}] ✗ ${msg}`), + done: () => {} + } + }); + + localResults.push(result); + setResults([...localResults]); + } + + if (outputPath) { + await safeWriteFile(outputPath, JSON.stringify(localResults, null, 2), true); + } + + setStatus("complete"); + setMessage("Batch processing complete!"); + } + + useInput((input, key) => { + if (key.escape || input.toLowerCase() === "q") { + app.exit(); + return; + } + + if (status === "select-orgs") { + if (key.upArrow) { + setCursorIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setCursorIndex((prev) => Math.min(orgs.length - 1, prev + 1)); + } else if (input === " ") { + setSelectedOrgIndices((prev) => { + const next = new Set(prev); + if (next.has(cursorIndex)) { + next.delete(cursorIndex); + } else { + next.add(cursorIndex); + } + return next; + }); + } else if (key.return && selectedOrgIndices.size > 0) { + loadProjects().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Failed to load projects"); + }); + } + } + + if (status === "select-projects") { + if (key.upArrow) { + setCursorIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setCursorIndex((prev) => Math.min(projects.length - 1, prev + 1)); + } else if (input === " ") { + setSelectedProjectIndices((prev) => { + const next = new Set(prev); + if (next.has(cursorIndex)) { + next.delete(cursorIndex); + } else { + next.add(cursorIndex); + } + return next; + }); + } else if (key.return && selectedProjectIndices.size > 0) { + loadRepos().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Failed to load repos"); + }); + } + } + + if (status === "select-repos") { + if (key.upArrow) { + setCursorIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setCursorIndex((prev) => Math.min(repos.length - 1, prev + 1)); + } else if (input === " ") { + setSelectedRepoIndices((prev) => { + const next = new Set(prev); + if (next.has(cursorIndex)) { + next.delete(cursorIndex); + } else { + next.add(cursorIndex); + } + return next; + }); + } else if (input.toLowerCase() === "a") { + const indicesWithoutInstructions = repos + .map((r, i) => ({ r, i })) + .filter(({ r }) => !r.hasInstructions) + .map(({ i }) => i); + setSelectedRepoIndices(new Set(indicesWithoutInstructions)); + } else if (key.return && selectedRepoIndices.size > 0) { + setStatus("confirm"); + setMessage( + `Ready to process ${selectedRepoIndices.size} repositories. Press Y to confirm, N to go back.` + ); + } + } + + if (status === "confirm") { + if (input.toLowerCase() === "y") { + processRepos().catch((err) => { + setStatus("error"); + setErrorMessage(err instanceof Error ? err.message : "Processing failed"); + }); + } else if (input.toLowerCase() === "n") { + setStatus("select-repos"); + setMessage("Select repos (space to toggle, enter to confirm)"); + } + } + }); + + const windowSize = 15; + const getVisibleItems = (items: T[], cursor: number): { items: T[]; startIndex: number } => { + const start = Math.max(0, cursor - Math.floor(windowSize / 2)); + const end = Math.min(items.length, start + windowSize); + const adjustedStart = Math.max(0, end - windowSize); + return { items: items.slice(adjustedStart, end), startIndex: adjustedStart }; + }; + + return ( + + + Batch Processing - Azure DevOps + + {message} + + + {status === "error" && ( + + Error: {errorMessage} + + )} + + {status === "select-orgs" && ( + + {(() => { + const { items: visibleOrgs, startIndex } = getVisibleItems(orgs, cursorIndex); + return visibleOrgs.map((org, i) => { + const realIndex = startIndex + i; + const isSelected = selectedOrgIndices.has(realIndex); + const isCursor = realIndex === cursorIndex; + return ( + + {isCursor ? "❯ " : " "} + {isSelected ? "◉" : "○"} + {org.name} + + ); + }); + })()} + {orgs.length > windowSize && ( + + Showing {Math.min(windowSize, orgs.length)} of {orgs.length} • Use ↑↓ to scroll + + )} + + )} + + {status === "select-projects" && ( + + {(() => { + const { items: visibleProjects, startIndex } = getVisibleItems(projects, cursorIndex); + return visibleProjects.map((project, i) => { + const realIndex = startIndex + i; + const isSelected = selectedProjectIndices.has(realIndex); + const isCursor = realIndex === cursorIndex; + return ( + + {isCursor ? "❯ " : " "} + {isSelected ? "◉" : "○"} + + {project.organization}/{project.name} + + + ); + }); + })()} + {projects.length > windowSize && ( + + Showing {Math.min(windowSize, projects.length)} of {projects.length} • Use ↑↓ to + scroll + + )} + + )} + + {status === "select-repos" && ( + + {(() => { + const { items: visibleRepos, startIndex } = getVisibleItems(repos, cursorIndex); + return visibleRepos.map((repo, i) => { + const realIndex = startIndex + i; + const isSelected = selectedRepoIndices.has(realIndex); + const isCursor = realIndex === cursorIndex; + return ( + + {isCursor ? "❯ " : " "} + {isSelected ? "◉" : "○"} + + {repo.hasInstructions ? "✓" : "✗"}{" "} + + + {repo.organization}/{repo.project}/{repo.name} + + {repo.isPrivate && (private)} + + ); + }); + })()} + {repos.length > windowSize && ( + + Showing {Math.min(windowSize, repos.length)} of {repos.length} • Use ↑↓ to scroll + + )} + + + Selected: {selectedRepoIndices.size} repos + + + + )} + + {status === "processing" && ( + + {processingMessage} + {results.length > 0 && ( + + Completed: + {results.slice(-5).map((r) => ( + + {r.success ? "✓" : "✗"} {r.repo} + {r.success && r.prUrl && → {r.prUrl}} + {!r.success && r.error && ({r.error})} + + ))} + + )} + + )} + + {status === "complete" && ( + + + ✓ Batch complete: {results.filter((r) => r.success).length} succeeded,{" "} + {results.filter((r) => !r.success).length} failed + + + {results.map((r) => ( + + {r.success ? "✓" : "✗"} {r.repo} + {r.success && r.prUrl && → {r.prUrl}} + {!r.success && r.error && ({r.error})} + + ))} + + + )} + + + {status === "select-orgs" && ( + Keys: [Space] Toggle [Enter] Confirm [Q] Quit + )} + {status === "select-projects" && ( + Keys: [Space] Toggle [Enter] Confirm [Q] Quit + )} + {status === "select-repos" && ( + Keys: [Space] Toggle [A] Select Missing [Enter] Confirm [Q] Quit + )} + {status === "confirm" && ( + Keys: [Y] Yes, proceed [N] Go back [Q] Quit + )} + {(status === "complete" || status === "error") && Keys: [Q] Quit} + + + ); +} diff --git a/src/ui/README.md b/src/ui/README.md index a34f7c0..551d04e 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -1 +1,3 @@ Primer TUI components live here. + +Run the TUI with `primer tui`. diff --git a/src/ui/index.ts b/src/ui/index.ts deleted file mode 100644 index c3505a6..0000000 --- a/src/ui/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./tui"; -export * from "./AnimatedBanner"; -export * from "./BatchTui"; diff --git a/src/ui/tui.tsx b/src/ui/tui.tsx index cfa8dbb..b0d7d37 100644 --- a/src/ui/tui.tsx +++ b/src/ui/tui.tsx @@ -1,214 +1,1245 @@ -import React, { useMemo, useState } from "react"; -import { Box, Key, Text, useApp, useInput } from "ink"; import fs from "fs/promises"; import path from "path"; -import { analyzeRepo, RepoAnalysis } from "../services/analyzer"; -import { generateCopilotInstructions } from "../services/instructions"; + +import type { Key } from "ink"; +import { Box, Text, useApp, useInput, useStdout } from "ink"; +import React, { useEffect, useMemo, useState } from "react"; + +import type { RepoApp, Area } from "../services/analyzer"; +import { analyzeRepo } from "../services/analyzer"; +import { getAzureDevOpsToken } from "../services/azureDevops"; +import { listCopilotModels } from "../services/copilot"; +import { generateEvalScaffold } from "../services/evalScaffold"; +import type { EvalConfig } from "../services/evalScaffold"; import { runEval, type EvalResult } from "../services/evaluator"; +import { getGitHubToken } from "../services/github"; +import { + generateCopilotInstructions, + generateAreaInstructions, + buildAreaInstructionContent, + areaInstructionPath, + writeAreaInstruction +} from "../services/instructions"; +import { safeWriteFile, buildTimestampedName } from "../utils/fs"; + import { AnimatedBanner, StaticBanner } from "./AnimatedBanner"; import { BatchTui } from "./BatchTui"; -import { getGitHubToken } from "../services/github"; +import { BatchTuiAzure } from "./BatchTuiAzure"; type Props = { repoPath: string; skipAnimation?: boolean; }; -type Status = "intro" | "idle" | "analyzing" | "generating" | "evaluating" | "preview" | "done" | "error" | "batch"; +type Status = + | "intro" + | "idle" + | "generating" + | "bootstrapping" + | "evaluating" + | "preview" + | "done" + | "error" + | "batch-pick" + | "batch-github" + | "batch-azure" + | "eval-pick" + | "model-pick" + | "generate-pick" + | "generate-app-pick" + | "generate-area-pick" + | "generating-areas" + | "bootstrapEvalCount" + | "bootstrapEvalConfirm"; + +type LogEntry = { + text: string; + type: "info" | "success" | "error" | "progress"; + time: string; +}; + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/** Track terminal columns reactively so resize triggers a re-render. */ +function useTerminalColumns(): number { + const { stdout } = useStdout(); + const [columns, setColumns] = useState(stdout.columns ?? 80); + useEffect(() => { + const onResize = () => setColumns(stdout.columns ?? 80); + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + return columns; +} + +function useSpinner(active: boolean): string { + const [frame, setFrame] = useState(0); + useEffect(() => { + if (!active) return; + const interval = setInterval(() => { + setFrame((f) => (f + 1) % SPINNER_FRAMES.length); + }, 80); + return () => clearInterval(interval); + }, [active]); + return active ? SPINNER_FRAMES[frame] : ""; +} + +function timestamp(): string { + return new Date().toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +} + +function KeyHint({ k, label }: { k: string; label: string }): React.JSX.Element { + return ( + + + {"["} + + + {k} + + + {"]"} + + {label} + + ); +} + +function Divider({ label, columns }: { label?: string; columns: number }): React.JSX.Element { + // Account for root Box border (2) + padding (2) = 4 chars + const innerWidth = Math.max(0, columns - 4); + if (label) { + const prefix = "── "; + const suffix = " "; + const used = prefix.length + label.length + suffix.length; + const fill = "─".repeat(Math.max(1, innerWidth - used)); + return ( + + + {prefix} + + + {label} + + + {suffix} + {fill} + + + ); + } + const fill = "─".repeat(Math.max(1, innerWidth)); + return ( + + + {fill} + + + ); +} + +const PREFERRED_MODELS = ["claude-sonnet-4.5", "claude-sonnet-4", "gpt-4.1", "gpt-5"]; + +function pickBestModel(available: string[], fallback: string): string { + for (const preferred of PREFERRED_MODELS) { + if (available.includes(preferred)) return preferred; + } + return available[0] || fallback; +} export function PrimerTui({ repoPath, skipAnimation = false }: Props): React.JSX.Element { const app = useApp(); + const terminalColumns = useTerminalColumns(); const [status, setStatus] = useState(skipAnimation ? "idle" : "intro"); - const [analysis, setAnalysis] = useState(null); const [message, setMessage] = useState(""); const [generatedContent, setGeneratedContent] = useState(""); const [evalResults, setEvalResults] = useState(null); + const [evalViewerPath, setEvalViewerPath] = useState(null); const [batchToken, setBatchToken] = useState(null); - const repoLabel = useMemo(() => repoPath, [repoPath]); + const [batchAzureToken, setBatchAzureToken] = useState(null); + const [evalCaseCountInput, setEvalCaseCountInput] = useState(""); + const [evalBootstrapCount, setEvalBootstrapCount] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [evalModel, setEvalModel] = useState("claude-sonnet-4.5"); + const [judgeModel, setJudgeModel] = useState("claude-sonnet-4.5"); + const [hideModelPicker, setHideModelPicker] = useState(false); + const [modelPickTarget, setModelPickTarget] = useState<"eval" | "judge">("eval"); + const [modelCursor, setModelCursor] = useState(0); + const [hasEvalConfig, setHasEvalConfig] = useState(null); + const [activityLog, setActivityLog] = useState([]); + const [generateTarget, setGenerateTarget] = useState<"copilot-instructions" | "agents-md">( + "copilot-instructions" + ); + const [generateSavePath, setGenerateSavePath] = useState(""); + const [repoApps, setRepoApps] = useState([]); + const [isMonorepo, setIsMonorepo] = useState(false); + const [repoAreas, setRepoAreas] = useState([]); + const [areaCursor, setAreaCursor] = useState(0); + const repoLabel = useMemo(() => path.basename(repoPath), [repoPath]); + const repoFull = useMemo(() => repoPath, [repoPath]); + const isLoading = + status === "generating" || + status === "bootstrapping" || + status === "evaluating" || + status === "generating-areas"; + const isMenu = + status === "model-pick" || + status === "eval-pick" || + status === "batch-pick" || + status === "generate-pick" || + status === "generate-app-pick" || + status === "generate-area-pick"; + const spinner = useSpinner(isLoading); + + const addLog = (text: string, type: LogEntry["type"] = "info") => { + setActivityLog((prev) => [...prev.slice(-4), { text, type, time: timestamp() }]); + }; const handleAnimationComplete = () => { setStatus("idle"); }; - useInput(async (input: string, key: Key) => { - // During intro animation, any key skips it - if (status === "intro") { - setStatus("idle"); - return; - } + // Check for eval config and repo structure on mount + useEffect(() => { + const configPath = path.join(repoPath, "primer.eval.json"); + fs.access(configPath) + .then(() => setHasEvalConfig(true)) + .catch(() => setHasEvalConfig(false)); + analyzeRepo(repoPath) + .then((analysis) => { + const apps = analysis.apps ?? []; + setRepoApps(apps); + setIsMonorepo(analysis.isMonorepo ?? false); + setRepoAreas(analysis.areas ?? []); + }) + .catch((err) => { + addLog(`Repo analysis failed: ${err instanceof Error ? err.message : "unknown"}`, "error"); + }); + }, [repoPath]); - if (key.escape || input.toLowerCase() === "q") { - app.exit(); - return; - } + useEffect(() => { + let active = true; + listCopilotModels() + .then((models) => { + if (!active) return; + setAvailableModels(models); + if (models.length === 0) return; + setEvalModel((current) => + models.includes(current) ? current : pickBestModel(models, current) + ); + setJudgeModel((current) => + models.includes(current) ? current : pickBestModel(models, current) + ); + }) + .catch(() => { + if (!active) return; + setAvailableModels([]); + }); + return () => { + active = false; + }; + }, []); - // In preview mode, handle save/discard - if (status === "preview") { - if (input.toLowerCase() === "s") { - try { - const outputPath = path.join(repoPath, ".github", "copilot-instructions.md"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, generatedContent, "utf8"); - setStatus("done"); - setMessage("Saved to .github/copilot-instructions.md"); - setGeneratedContent(""); - } catch (error) { - setStatus("error"); - setMessage(error instanceof Error ? error.message : "Failed to save."); - } - return; + const doGenerate = async ( + targetRepoPath: string, + savePath: string, + target: string + ): Promise => { + setStatus("generating"); + setMessage(`Generating ${target}...`); + addLog(`Generating ${target}...`, "progress"); + try { + const content = await generateCopilotInstructions({ + repoPath: targetRepoPath, + onProgress: (msg) => setMessage(msg) + }); + if (!content.trim()) { + throw new Error("Copilot SDK returned empty content."); } - if (input.toLowerCase() === "d") { - setStatus("idle"); - setMessage("Discarded generated instructions."); - setGeneratedContent(""); - return; + setGeneratedContent(content); + setGenerateSavePath(savePath); + setStatus("preview"); + setMessage("Review the generated content below."); + addLog(`${target} generated — review and save.`, "success"); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Generation failed."; + if (msg.toLowerCase().includes("auth") || msg.toLowerCase().includes("login")) { + setMessage(`${msg} Run 'copilot' then '/login' in a separate terminal.`); + } else { + setMessage(msg); } - return; + addLog(msg, "error"); } + }; - if (input.toLowerCase() === "a") { - setStatus("analyzing"); - try { - const result = await analyzeRepo(repoPath); - setAnalysis(result); - setStatus("done"); - setMessage("Analysis complete."); - } catch (error) { - setStatus("error"); - setMessage(error instanceof Error ? error.message : "Analysis failed."); - } - return; - } + useEffect(() => { + let active = true; + const configPath = path.join(repoPath, "primer.eval.json"); + fs.readFile(configPath, "utf8") + .then((raw) => { + if (!active) return; + const parsed = JSON.parse(raw) as EvalConfig; + const setting = parsed.ui?.modelPicker; + setHideModelPicker(setting === "hidden"); + }) + .catch(() => { + if (!active) return; + setHideModelPicker(false); + }); + return () => { + active = false; + }; + }, [repoPath]); - if (input.toLowerCase() === "g") { - setStatus("generating"); - setMessage("Starting generation..."); - try { - const content = await generateCopilotInstructions({ - repoPath, - onProgress: (msg) => setMessage(msg), - }); - if (!content.trim()) { - throw new Error("Copilot SDK returned empty instructions."); - } - setGeneratedContent(content); - setStatus("preview"); - setMessage("Review the generated instructions below."); - } catch (error) { - setStatus("error"); - const message = error instanceof Error ? error.message : "Generation failed."; - if (message.toLowerCase().includes("auth") || message.toLowerCase().includes("login")) { - setMessage(`${message} Run 'copilot' then '/login' in a separate terminal.`); - } else { - setMessage(message); - } - } + const bootstrapEvalConfig = async (count: number, force: boolean): Promise => { + const configPath = path.join(repoPath, "primer.eval.json"); + try { + setStatus("bootstrapping"); + setMessage("Generating eval cases with Copilot SDK..."); + addLog("Generating eval scaffold...", "progress"); + const config = await generateEvalScaffold({ + repoPath, + count, + model: evalModel, + onProgress: (msg) => setMessage(msg) + }); + await safeWriteFile(configPath, JSON.stringify(config, null, 2), force); + setHasEvalConfig(true); + setStatus("idle"); + const msg = `Generated primer.eval.json with ${config.cases.length} cases.`; + setMessage(msg); + addLog(msg, "success"); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Failed to generate eval config."; + setMessage(msg); + addLog(msg, "error"); + } finally { + setEvalCaseCountInput(""); + setEvalBootstrapCount(null); } + }; - if (input.toLowerCase() === "b") { - setStatus("analyzing"); - setMessage("Checking GitHub authentication..."); - const token = await getGitHubToken(); - if (!token) { - setStatus("error"); - setMessage("GitHub auth required. Run 'gh auth login' or set GITHUB_TOKEN."); - return; - } - setBatchToken(token); - setStatus("batch"); - return; - } + // NOTE: The useInput handler below is intentionally kept as a single callback + // to avoid prop-drilling ~20 state setters. If this grows further, consider + // extracting each status into a sub-component with its own useInput hook. + const inputActive = status !== "batch-github" && status !== "batch-azure"; + useInput( + (input: string, key: Key) => { + void (async () => { + try { + if (status === "intro") { + setStatus("idle"); + return; + } - if (input.toLowerCase() === "e") { - const configPath = path.join(repoPath, "primer.eval.json"); - try { - await fs.access(configPath); - } catch { - setStatus("error"); - setMessage("No primer.eval.json found. Run 'primer eval --init' to create one."); - return; - } - - setStatus("evaluating"); - setMessage("Running evals... (this may take a few minutes)"); - setEvalResults(null); - try { - const { results } = await runEval({ - configPath, - repoPath, - model: "gpt-4.1", - judgeModel: "gpt-4.1", - // Note: onProgress removed - causes issues with SDK in React/Ink context - }); - setEvalResults(results); - const passed = results.filter(r => r.verdict === "pass").length; - const failed = results.filter(r => r.verdict === "fail").length; - setStatus("done"); - setMessage(`Eval complete: ${passed} pass, ${failed} fail out of ${results.length} cases.`); - } catch (error) { - setStatus("error"); - setMessage(error instanceof Error ? error.message : "Eval failed."); - } - } - }); + if (status === "preview") { + if (input.toLowerCase() === "s") { + try { + const outputPath = + generateSavePath || path.join(repoPath, ".github", "copilot-instructions.md"); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const { wrote, reason } = await safeWriteFile(outputPath, generatedContent, true); + if (!wrote) + throw new Error(reason === "symlink" ? "Path is a symlink" : "Write failed"); + setStatus("done"); + const relPath = path.relative(repoPath, outputPath); + const msg = `Saved to ${relPath}`; + setMessage(msg); + addLog(msg, "success"); + setGeneratedContent(""); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Failed to save."; + setMessage(msg); + addLog(msg, "error"); + } + return; + } + if (input.toLowerCase() === "d") { + setStatus("idle"); + setMessage("Discarded generated instructions."); + addLog("Discarded instructions.", "info"); + setGeneratedContent(""); + return; + } + if (key.escape || input.toLowerCase() === "q") { + app.exit(); + return; + } + return; + } + + if (status === "bootstrapEvalCount") { + if (key.return) { + const trimmed = evalCaseCountInput.trim(); + const count = Number.parseInt(trimmed, 10); + if (!trimmed || !Number.isFinite(count) || count <= 0) { + setMessage("Enter a positive number of eval cases, then press Enter."); + return; + } + + const configPath = path.join(repoPath, "primer.eval.json"); + setEvalBootstrapCount(count); + try { + await fs.access(configPath); + setStatus("bootstrapEvalConfirm"); + setMessage("primer.eval.json exists. Overwrite? (Y/N)"); + } catch { + await bootstrapEvalConfig(count, false); + } + return; + } + + if (key.backspace || key.delete) { + setEvalCaseCountInput((prev) => prev.slice(0, -1)); + return; + } + + if (/^\d$/.test(input)) { + setEvalCaseCountInput((prev) => prev + input); + return; + } + + if (key.escape) { + setStatus("idle"); + setMessage(""); + setEvalCaseCountInput(""); + setEvalBootstrapCount(null); + return; + } + return; + } + + if (status === "bootstrapEvalConfirm") { + if (input.toLowerCase() === "y") { + const count = evalBootstrapCount ?? 0; + if (count <= 0) { + setStatus("error"); + setMessage("Missing eval case count. Restart bootstrap."); + return; + } + await bootstrapEvalConfig(count, true); + return; + } + + if (input.toLowerCase() === "n" || key.escape) { + setStatus("idle"); + setMessage("Bootstrap cancelled."); + setEvalCaseCountInput(""); + setEvalBootstrapCount(null); + return; + } + if (input.toLowerCase() === "q") { + app.exit(); + return; + } + return; + } + + if (status === "generate-pick") { + if (input.toLowerCase() === "c") { + setGenerateTarget("copilot-instructions"); + if (isMonorepo && repoApps.length > 1) { + setStatus("generate-app-pick"); + setMessage("Generate for root or per-app?"); + } else { + const savePath = path.join(repoPath, ".github", "copilot-instructions.md"); + setGenerateSavePath(savePath); + await doGenerate(repoPath, savePath, "copilot-instructions"); + } + return; + } + if (input.toLowerCase() === "a") { + setGenerateTarget("agents-md"); + if (isMonorepo && repoApps.length > 1) { + setStatus("generate-app-pick"); + setMessage("Generate for root or per-app?"); + } else { + const savePath = path.join(repoPath, "AGENTS.md"); + setGenerateSavePath(savePath); + await doGenerate(repoPath, savePath, "agents-md"); + } + return; + } + if (input.toLowerCase() === "f") { + if (repoAreas.length === 0) { + setMessage("No areas detected. Add primer.config.json to define areas."); + return; + } + setAreaCursor(0); + setStatus("generate-area-pick"); + setMessage("Generate file-based instructions for areas."); + return; + } + if (key.escape) { + setStatus("idle"); + setMessage(""); + return; + } + return; + } + + if (status === "generate-app-pick") { + if (input.toLowerCase() === "r") { + // Root only + const savePath = + generateTarget === "copilot-instructions" + ? path.join(repoPath, ".github", "copilot-instructions.md") + : path.join(repoPath, "AGENTS.md"); + setGenerateSavePath(savePath); + await doGenerate(repoPath, savePath, generateTarget); + return; + } + if (input.toLowerCase() === "a") { + // All apps sequentially + setStatus("generating"); + addLog(`Generating ${generateTarget} for ${repoApps.length} apps...`, "progress"); + let count = 0; + for (const app of repoApps) { + const savePath = + generateTarget === "copilot-instructions" + ? path.join(app.path, ".github", "copilot-instructions.md") + : path.join(app.path, "AGENTS.md"); + setMessage(`Generating for ${app.name} (${count + 1}/${repoApps.length})...`); + try { + const content = await generateCopilotInstructions({ + repoPath: app.path, + onProgress: (msg) => setMessage(`${app.name}: ${msg}`) + }); + if (content.trim()) { + await fs.mkdir(path.dirname(savePath), { recursive: true }); + const { wrote: saved } = await safeWriteFile(savePath, content, true); + if (!saved) continue; + count++; + addLog(`${app.name}: saved ${path.basename(savePath)}`, "success"); + } + } catch (error) { + const msg = error instanceof Error ? error.message : "Failed."; + addLog(`${app.name}: ${msg}`, "error"); + } + } + setStatus("done"); + setMessage(`Generated ${generateTarget} for ${count}/${repoApps.length} apps.`); + return; + } + // Number to pick a specific app + const num = Number.parseInt(input, 10); + if (Number.isFinite(num) && num >= 1 && num <= repoApps.length) { + const app = repoApps[num - 1]; + const savePath = + generateTarget === "copilot-instructions" + ? path.join(app.path, ".github", "copilot-instructions.md") + : path.join(app.path, "AGENTS.md"); + setGenerateSavePath(savePath); + await doGenerate(app.path, savePath, generateTarget); + return; + } + if (key.escape) { + setStatus("generate-pick"); + setMessage("Select what to generate."); + return; + } + return; + } + + if (status === "generate-area-pick") { + if (input.toLowerCase() === "a") { + // All areas + setStatus("generating-areas"); + addLog( + `Generating file-based instructions for ${repoAreas.length} areas...`, + "progress" + ); + let written = 0; + for (const [i, area] of repoAreas.entries()) { + setMessage(`Generating for "${area.name}" (${i + 1}/${repoAreas.length})...`); + try { + const body = await generateAreaInstructions({ + repoPath, + area, + onProgress: (msg) => setMessage(`${area.name}: ${msg}`) + }); + const result = await writeAreaInstruction(repoPath, area, body); + if (result.status === "written") { + written++; + addLog(`${area.name}: saved ${path.basename(result.filePath)}`, "success"); + } else if (result.status === "skipped") { + addLog(`${area.name}: skipped (file exists)`, "info"); + } + } catch (error) { + const msg = error instanceof Error ? error.message : "Failed."; + addLog(`${area.name}: ${msg}`, "error"); + } + } + setStatus("done"); + setMessage( + `Generated file-based instructions for ${written}/${repoAreas.length} areas.` + ); + return; + } + if (key.upArrow) { + setAreaCursor((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setAreaCursor((prev) => Math.min(repoAreas.length - 1, prev + 1)); + return; + } + if (key.return) { + const area = repoAreas[areaCursor]; + if (!area) return; + setStatus("generating-areas"); + setMessage(`Generating for "${area.name}"...`); + addLog(`Generating file-based instructions for "${area.name}"...`, "progress"); + try { + const body = await generateAreaInstructions({ + repoPath, + area, + onProgress: (msg) => setMessage(`${area.name}: ${msg}`) + }); + if (body.trim()) { + const filePath = areaInstructionPath(repoPath, area); + setGeneratedContent(buildAreaInstructionContent(area, body)); + setGenerateSavePath(filePath); + setStatus("preview"); + setMessage("Review the generated area instructions."); + addLog(`"${area.name}" generated — review and save.`, "success"); + } else { + setStatus("done"); + setMessage(`No content generated for "${area.name}".`); + } + } catch (error) { + const msg = error instanceof Error ? error.message : "Failed."; + setStatus("error"); + setMessage(msg); + addLog(`${area.name}: ${msg}`, "error"); + } + return; + } + if (key.escape) { + setStatus("generate-pick"); + setMessage("Select what to generate."); + return; + } + return; + } + + if (status === "model-pick") { + if (key.escape) { + setStatus("idle"); + setMessage(""); + return; + } + if (key.upArrow) { + setModelCursor((prev) => Math.max(0, prev - 1)); + return; + } + if (key.downArrow) { + setModelCursor((prev) => Math.min(availableModels.length - 1, prev + 1)); + return; + } + if (key.return) { + const chosen = availableModels[modelCursor]; + if (chosen) { + if (modelPickTarget === "eval") { + setEvalModel(chosen); + addLog(`Eval model → ${chosen}`, "success"); + } else { + setJudgeModel(chosen); + addLog(`Judge model → ${chosen}`, "success"); + } + setStatus("idle"); + setMessage( + `${modelPickTarget === "eval" ? "Eval" : "Judge"} model set to ${chosen}` + ); + } + return; + } + return; + } - const statusLabel = status === "intro" ? "..." : status === "idle" ? "ready (awaiting input)" : status; + if (status === "eval-pick") { + if (input.toLowerCase() === "r") { + // Run eval + const configPath = path.join(repoPath, "primer.eval.json"); + const outputPath = path.join( + repoPath, + ".primer", + "evals", + buildTimestampedName("eval-results") + ); + try { + await fs.access(configPath); + } catch { + setStatus("error"); + const msg = "No primer.eval.json found. Press [E] then [I] to create one."; + setMessage(msg); + addLog(msg, "error"); + return; + } + + setStatus("evaluating"); + setMessage("Running evals... (this may take a few minutes)"); + addLog("Running evals...", "progress"); + setEvalResults(null); + setEvalViewerPath(null); + try { + const { results, viewerPath } = await runEval({ + configPath, + repoPath, + model: evalModel, + judgeModel: judgeModel, + outputPath + }); + setEvalResults(results); + setEvalViewerPath(viewerPath ?? null); + const passed = results.filter((r) => r.verdict === "pass").length; + const failed = results.filter((r) => r.verdict === "fail").length; + setStatus("done"); + const msg = `Eval complete: ${passed} pass, ${failed} fail out of ${results.length} cases.`; + setMessage(msg); + addLog(msg, "success"); + } catch (error) { + setStatus("error"); + const msg = error instanceof Error ? error.message : "Eval failed."; + setMessage(msg); + addLog(msg, "error"); + } + return; + } + if (input.toLowerCase() === "i") { + setStatus("bootstrapEvalCount"); + setMessage("Enter number of eval cases, then press Enter."); + setEvalCaseCountInput(""); + setEvalBootstrapCount(null); + return; + } + if (key.escape || input.toLowerCase() === "b") { + setStatus("idle"); + setMessage(""); + return; + } + return; + } + + if (status === "batch-pick") { + if (input.toLowerCase() === "g") { + setStatus("generating"); + setMessage("Checking GitHub authentication..."); + addLog("Starting batch (GitHub)...", "progress"); + const token = await getGitHubToken(); + if (!token) { + setStatus("error"); + const msg = "GitHub auth required. Run 'gh auth login' or set GITHUB_TOKEN."; + setMessage(msg); + addLog(msg, "error"); + return; + } + setBatchToken(token); + setStatus("batch-github"); + return; + } + if (input.toLowerCase() === "a") { + setStatus("generating"); + setMessage("Checking Azure DevOps authentication..."); + addLog("Starting batch (Azure DevOps)...", "progress"); + const token = getAzureDevOpsToken(); + if (!token) { + setStatus("error"); + const msg = "Azure DevOps PAT required. Set AZURE_DEVOPS_PAT or AZDO_PAT."; + setMessage(msg); + addLog(msg, "error"); + return; + } + setBatchAzureToken(token); + setStatus("batch-azure"); + return; + } + if (key.escape || input.toLowerCase() === "b") { + setStatus("idle"); + setMessage(""); + return; + } + return; + } + + // Idle-state shortcuts — only active from idle, done, or error + if (status === "idle" || status === "done" || status === "error") { + if (input.toLowerCase() === "g") { + setStatus("generate-pick"); + setMessage("Select what to generate."); + return; + } + + if (input.toLowerCase() === "b") { + setStatus("batch-pick"); + setMessage("Select batch provider."); + return; + } + + if (input.toLowerCase() === "e") { + setStatus("eval-pick"); + setMessage("Select eval action."); + return; + } + + if (input.toLowerCase() === "m") { + if (hideModelPicker) { + setMessage( + 'Model picker hidden. Set ui.modelPicker to "visible" in primer.eval.json.' + ); + return; + } + setModelPickTarget("eval"); + setStatus("model-pick"); + setMessage("Pick eval model."); + const idx = availableModels.indexOf(evalModel); + setModelCursor(idx >= 0 ? idx : 0); + return; + } + + if (input.toLowerCase() === "j") { + if (hideModelPicker) { + setMessage( + 'Model picker hidden. Set ui.modelPicker to "visible" in primer.eval.json.' + ); + return; + } + setModelPickTarget("judge"); + setStatus("model-pick"); + setMessage("Pick judge model."); + const idx = availableModels.indexOf(judgeModel); + setModelCursor(idx >= 0 ? idx : 0); + return; + } + } + + if (key.escape || input.toLowerCase() === "q") { + app.exit(); + return; + } + } catch (err) { + setStatus("error"); + setMessage(err instanceof Error ? err.message : "Unexpected error"); + } + })(); + }, + { isActive: inputActive } + ); + + const statusIcon = status === "error" ? "✗" : status === "done" ? "✓" : isLoading ? spinner : "●"; + const statusLabel = + status === "intro" + ? "starting" + : status === "idle" + ? "ready" + : status === "bootstrapEvalCount" + ? "input" + : status === "bootstrapEvalConfirm" + ? "confirm" + : status === "eval-pick" + ? "eval" + : status === "batch-pick" + ? "batch" + : status === "model-pick" + ? "models" + : status; + const statusColor = + status === "error" + ? "red" + : status === "done" + ? "green" + : isLoading + ? "yellow" + : isMenu + ? "magentaBright" + : "cyanBright"; + + const formatTokens = (result: EvalResult): string => { + const withUsage = result.metrics?.withInstructions?.tokenUsage; + const withoutUsage = result.metrics?.withoutInstructions?.tokenUsage; + const withTotal = + withUsage?.totalTokens ?? + (withUsage ? (withUsage.promptTokens ?? 0) + (withUsage.completionTokens ?? 0) : undefined); + const withoutTotal = + withoutUsage?.totalTokens ?? + (withoutUsage + ? (withoutUsage.promptTokens ?? 0) + (withoutUsage.completionTokens ?? 0) + : undefined); + if (withTotal == null && withoutTotal == null) return "tokens n/a"; + return `tokens w/: ${withTotal ?? "n/a"} • w/o: ${withoutTotal ?? "n/a"}`; + }; - // Truncate preview to fit terminal const previewLines = generatedContent.split("\n").slice(0, 20); - const truncatedPreview = previewLines.join("\n") + (generatedContent.split("\n").length > 20 ? "\n..." : ""); + const truncatedPreview = + previewLines.join("\n") + (generatedContent.split("\n").length > 20 ? "\n..." : ""); - // Render BatchTui when in batch mode - if (status === "batch" && batchToken) { + if (status === "batch-github" && batchToken) { return ; } + if (status === "batch-azure" && batchAzureToken) { + return ; + } + + const innerWidth = Math.max(0, terminalColumns - 4); + return ( - + {status === "intro" ? ( - + ) : ( - + )} - Prime your repo for AI. - Repo: {repoLabel} - - Status: {statusLabel} - {analysis && ( - - Languages: {analysis.languages.join(", ") || "unknown"} - Frameworks: {analysis.frameworks.join(", ") || "none"} - Package manager: {analysis.packageManager ?? "unknown"} - - )} + + {/* Status Bar */} + + + Prime your repo for AI + + + {statusIcon} {statusLabel} + - - {message} + + {/* Context */} + + + + Repo + + {repoLabel} + + {isMonorepo && monorepo · {repoApps.length} apps} + {repoAreas.length > 0 && · {repoAreas.length} areas} + + {" "} + {repoFull} + + + + Model + {evalModel} + • Judge + {judgeModel} + {availableModels.length > 0 && ( + + {" "} + ({availableModels.length} available) + + )} + + + Eval + {hasEvalConfig === null ? ( + + checking... + + ) : hasEvalConfig ? ( + primer.eval.json found + ) : ( + no eval config — press [I] to create + )} + + + + {/* Activity */} + + + {activityLog.length === 0 && !message ? ( + + Awaiting input. + + ) : ( + <> + {activityLog.slice(-3).map((entry, i) => ( + + + {entry.time}{" "} + + + {entry.text} + + + ))} + {message && !activityLog.some((e) => e.text === message) && ( + + + {isLoading ? `${spinner} ` : ""} + {message} + + + )} + + )} + + {/* Model Picker */} + {status === "model-pick" && availableModels.length > 0 && ( + <> + + + {availableModels.map((model, i) => { + const current = modelPickTarget === "eval" ? evalModel : judgeModel; + const isCurrent = model === current; + const isCursor = i === modelCursor; + return ( + + {isCursor ? "\u276F " : " "} + + {model} + + {isCurrent && ( + + {" "} + (current) + + )} + + ); + })} + {availableModels.length > 15 && ( + + Use {"\u2191\u2193"} to scroll + + )} + + + )} + + {/* App picker for monorepo generate */} + {status === "generate-app-pick" && repoApps.length > 0 && ( + <> + + + {repoApps.map((app, i) => ( + + + {i + 1} + + + {app.name} + + {" "} + {path.relative(repoPath, app.path)} + + + ))} + + + )} + + {/* Area picker for file-based instructions */} + {status === "generate-area-pick" && repoAreas.length > 0 && ( + <> + + + {repoAreas.map((area, i) => ( + + + {i === areaCursor ? "▶" : " "} + + + + {area.name} + + + {" "} + {Array.isArray(area.applyTo) ? area.applyTo.join(", ") : area.applyTo} + + {area.source === "config" && ( + + {" "} + (config) + + )} + + ))} + + + )} + + {/* Input: eval case count */} + {status === "bootstrapEvalCount" && ( + + Eval case count: + + {evalCaseCountInput || "▍"} + + + )} + + {/* Preview */} {status === "preview" && generatedContent && ( - - Preview (.github/copilot-instructions.md): + + + Preview ({path.relative(repoPath, generateSavePath) || generateTarget}) + {truncatedPreview} )} + + {/* Eval Results */} {evalResults && evalResults.length > 0 && ( - - Eval Results: - {evalResults.map((r) => ( - - {r.verdict === "pass" ? "✓" : r.verdict === "fail" ? "✗" : "?"} {r.id}: {r.verdict} (score: {r.score}) - - ))} - + <> + + + {evalResults.map((r) => ( + + + {r.verdict === "pass" ? "✓" : r.verdict === "fail" ? "✗" : "?"}{" "} + + {r.id} + + {" "} + score:{r.score} • {formatTokens(r)} + + + ))} + {evalViewerPath && ( + + Viewer: {evalViewerPath} + + )} + + )} - + + {/* Commands */} + + {status === "intro" ? ( Press any key to skip animation... ) : status === "preview" ? ( - Keys: [S] Save [D] Discard [Q] Quit + + + + + + ) : status === "bootstrapEvalConfirm" ? ( + + + + + + ) : status === "model-pick" ? ( + + Use + + {"\u2191\u2193"} + + to navigate, + + Enter + + to select + + + ) : status === "generate-pick" ? ( + + + + {repoAreas.length > 0 && } + + + ) : status === "generate-app-pick" ? ( + + + + + {" "} + or press{" "} + + + 1 + + + - + + + {repoApps.length} + + + {" "} + + + + ) : status === "generate-area-pick" ? ( + + + + + + + ) : status === "bootstrapEvalCount" ? ( + + Type number, then + + Enter + + to confirm + + + ) : status === "eval-pick" ? ( + + + + + + ) : status === "batch-pick" ? ( + + + + + + ) : isLoading ? ( + + + ) : ( - Keys: [A] Analyze [G] Generate [E] Eval [B] Batch [Q] Quit + + + + + + + + + + + + )} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 0c4a1f6..f05a94c 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,3 +1,4 @@ +import { constants as fsConstants } from "fs"; import fs from "fs/promises"; import path from "path"; @@ -5,17 +6,287 @@ export async function ensureDir(dirPath: string): Promise { await fs.mkdir(dirPath, { recursive: true }); } -export async function safeWriteFile(filePath: string, content: string, force: boolean): Promise { - const exists = await fileExists(filePath); - if (exists && !force) { - return `Skipped ${path.relative(process.cwd(), filePath)} (exists)`; +export type WriteResult = { wrote: boolean; reason?: "symlink" | "exists" }; + +export async function safeWriteFile( + filePath: string, + content: string, + force: boolean +): Promise { + const resolved = path.resolve(filePath); + const noFollowFlag = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW; + + if (await hasSymlinkAncestor(resolved)) { + return { wrote: false, reason: "symlink" }; + } + + await fs.mkdir(path.dirname(resolved), { recursive: true }); + if (await hasSymlinkAncestor(resolved)) { + return { wrote: false, reason: "symlink" }; + } + + if (process.platform === "win32") { + try { + const stat = await fs.lstat(resolved); + if (stat.isSymbolicLink()) { + return { wrote: false, reason: "symlink" }; + } + if (!force) { + return { wrote: false, reason: "exists" }; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } + } + + if (process.platform === "win32" && force) { + return replaceFileWindows(resolved, content); } - await fs.writeFile(filePath, content, "utf8"); - return `Wrote ${path.relative(process.cwd(), filePath)}`; + const flags = force + ? fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | noFollowFlag + : fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollowFlag; + + try { + const handle = await fs.open(resolved, flags, 0o666); + try { + await handle.writeFile(content, "utf8"); + } finally { + await handle.close(); + } + return { wrote: true }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST") { + try { + const stat = await fs.lstat(resolved); + if (stat.isSymbolicLink()) { + return { wrote: false, reason: "symlink" }; + } + } catch { + // Ignore stat errors and fall through to generic exists handling + } + return { wrote: false, reason: "exists" }; + } + if (code === "ELOOP") { + return { wrote: false, reason: "symlink" }; + } + throw error; + } } -async function fileExists(filePath: string): Promise { +async function replaceFileWindows(targetPath: string, content: string): Promise { + const parentDir = path.dirname(targetPath); + const tempPath = path.join( + parentDir, + `.primer-tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const backupPath = path.join( + parentDir, + `.primer-backup-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + + const tempHandle = await fs.open( + tempPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, + 0o666 + ); + try { + await tempHandle.writeFile(content, "utf8"); + } finally { + await tempHandle.close(); + } + + let movedOriginal = false; + let placedReplacement = false; + let restoredOriginal = false; + let restoreFailed = false; + try { + try { + const stat = await fs.lstat(targetPath); + if (stat.isSymbolicLink()) { + await fs.rm(tempPath, { force: true }); + return { wrote: false, reason: "symlink" }; + } + if (stat.isDirectory()) { + await fs.rm(tempPath, { force: true }); + return { wrote: false, reason: "exists" }; + } + await fs.rename(targetPath, backupPath); + movedOriginal = true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } + + await fs.rename(tempPath, targetPath); + placedReplacement = true; + return { wrote: true }; + } catch (error) { + await fs.rm(tempPath, { force: true }); + + if (movedOriginal) { + try { + await fs.rename(backupPath, targetPath); + restoredOriginal = true; + } catch { + restoreFailed = true; + } + } + + if (restoreFailed) { + throw new Error( + `Failed to restore original file after replacement failure; backup retained at ${backupPath}` + ); + } + + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST") { + try { + const stat = await fs.lstat(targetPath); + if (stat.isSymbolicLink()) { + return { wrote: false, reason: "symlink" }; + } + } catch { + // Ignore lstat errors and fall through + } + return { wrote: false, reason: "exists" }; + } + + throw error; + } finally { + if (movedOriginal && (placedReplacement || restoredOriginal)) { + await fs.rm(backupPath, { force: true }); + } + } +} + +async function hasSymlinkAncestor(filePath: string): Promise { + const parentDir = path.dirname(filePath); + const closestExistingAncestor = await findClosestExistingAncestor(parentDir); + const closestAncestorStat = await fs.lstat(closestExistingAncestor); + if (closestAncestorStat.isSymbolicLink()) { + return true; + } + + const realClosestAncestor = await fs.realpath(closestExistingAncestor); + if ( + realClosestAncestor !== closestExistingAncestor && + !isAllowedSystemAlias(closestExistingAncestor, realClosestAncestor) + ) { + // On Windows, 8.3 short filenames (e.g. RUNNER~1 → runneradmin) cause + // realpath to differ without any symlinks. Walk each ancestor component + // to check for actual symlinks before concluding. + if (process.platform === "win32") { + const parsed = path.parse(closestExistingAncestor); + const relative = path.relative(parsed.root, closestExistingAncestor); + const components = relative.split(path.sep).filter(Boolean); + let current = parsed.root; + let foundSymlink = false; + for (const component of components) { + current = path.join(current, component); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + foundSymlink = true; + break; + } + } catch { + break; + } + } + if (foundSymlink) { + return true; + } + } else { + return true; + } + } + + const relativeParent = path.relative(closestExistingAncestor, parentDir); + const segments = relativeParent.split(path.sep).filter(Boolean); + let currentPath = closestExistingAncestor; + + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + try { + const stat = await fs.lstat(currentPath); + if (stat.isSymbolicLink()) { + return true; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + break; + } + throw error; + } + } + + return false; +} + +async function findClosestExistingAncestor(targetDir: string): Promise { + let currentDir = targetDir; + + while (true) { + try { + await fs.lstat(currentDir); + return currentDir; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + + const nextDir = path.dirname(currentDir); + if (nextDir === currentDir) { + return currentDir; + } + currentDir = nextDir; + } + } +} + +function isAllowedSystemAlias(originalPath: string, realPath: string): boolean { + if (process.platform !== "darwin") { + return false; + } + + const allowsVarAlias = + (originalPath === "/var" || originalPath.startsWith("/var/")) && + (realPath === "/private/var" || realPath.startsWith("/private/var/")) && + originalPath.slice("/var".length) === realPath.slice("/private/var".length); + + const allowsTmpAlias = + (originalPath === "/tmp" || originalPath.startsWith("/tmp/")) && + (realPath === "/private/tmp" || realPath.startsWith("/private/tmp/")) && + originalPath.slice("/tmp".length) === realPath.slice("/private/tmp".length); + + return allowsVarAlias || allowsTmpAlias; +} + +/** + * Validate that constructed path segments stay within the expected root directory. + * Prevents traversal in the relative segments (e.g. "../../../etc") but does NOT + * validate the cacheRoot itself — callers are responsible for ensuring cacheRoot + * is a trusted path before passing it here. + */ +export function validateCachePath(cacheRoot: string, ...segments: string[]): string { + const resolvedRoot = path.resolve(cacheRoot); + const resolved = path.resolve(cacheRoot, ...segments); + if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) { + throw new Error(`Invalid path: escapes cache directory (${resolved})`); + } + return resolved; +} + +export async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; @@ -23,3 +294,25 @@ async function fileExists(filePath: string): Promise { return false; } } + +export async function safeReadDir(dirPath: string): Promise { + try { + return await fs.readdir(dirPath); + } catch { + return []; + } +} + +export async function readJson(filePath: string): Promise | undefined> { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as Record; + } catch { + return undefined; + } +} + +export function buildTimestampedName(baseName: string, extension = ".json"): string { + const stamp = new Date().toISOString().replace(/[:.]/gu, "-"); + return `${baseName}-${stamp}${extension}`; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 28f69a4..35e3bfe 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,11 +1,21 @@ import chalk from "chalk"; -import { RepoAnalysis } from "../services/analyzer"; + +import type { RepoAnalysis } from "../services/analyzer"; export function prettyPrintSummary(analysis: RepoAnalysis): void { - console.log(chalk.bold("Repository analysis")); - console.log(`- Path: ${analysis.path}`); - console.log(`- Git: ${analysis.isGitRepo ? "yes" : "no"}`); - console.log(`- Languages: ${analysis.languages.join(", ") || "unknown"}`); - console.log(`- Frameworks: ${analysis.frameworks.join(", ") || "none"}`); - console.log(`- Package manager: ${analysis.packageManager ?? "unknown"}`); + const log = (msg: string) => process.stderr.write(msg + "\n"); + log(chalk.bold("Repository analysis")); + log(`- Path: ${analysis.path}`); + log(`- Git: ${analysis.isGitRepo ? "yes" : "no"}`); + log(`- Languages: ${analysis.languages.join(", ") || "unknown"}`); + log(`- Frameworks: ${analysis.frameworks.join(", ") || "none"}`); + log(`- Package manager: ${analysis.packageManager ?? "unknown"}`); + if (analysis.isMonorepo) { + log( + `- Monorepo: yes (${analysis.workspaceType ?? "unknown"}, ${analysis.apps?.length ?? 0} apps)` + ); + } + if (analysis.areas && analysis.areas.length > 0) { + log(`- Areas: ${analysis.areas.map((a) => a.name).join(", ")}`); + } } diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..d1b996d --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,86 @@ +/** + * Structured output utilities for headless / JSON CLI mode. + * + * Convention: + * - stdout → machine-readable JSON (only when `json` flag is set) + * - stderr → human-readable progress, logs, and errors + */ + +export type CommandResult = { + ok: boolean; + status: "success" | "partial" | "noop" | "error"; + data?: T; + errors?: string[]; +}; + +export interface ProgressReporter { + update(message: string): void; + succeed(message: string): void; + fail(message: string): void; + done(): void; +} + +class HumanProgressReporter implements ProgressReporter { + update(message: string): void { + process.stderr.write(` ${message}\n`); + } + succeed(message: string): void { + process.stderr.write(` ✓ ${message}\n`); + } + fail(message: string): void { + process.stderr.write(` ✗ ${message}\n`); + } + done(): void { + /* noop */ + } +} + +class SilentProgressReporter implements ProgressReporter { + update(): void { + /* noop */ + } + succeed(): void { + /* noop */ + } + fail(): void { + /* noop */ + } + done(): void { + /* noop */ + } +} + +export function createProgressReporter(silent: boolean): ProgressReporter { + return silent ? new SilentProgressReporter() : new HumanProgressReporter(); +} + +export function shouldLog(options: { json?: boolean; quiet?: boolean }): boolean { + return !options.json && !options.quiet; +} + +export function outputResult(result: CommandResult, json: boolean): void { + if (json) { + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } +} + +export function deriveFileStatus(files: { action: "wrote" | "skipped" }[]): { + ok: boolean; + status: "success" | "partial" | "noop"; +} { + const hasWrites = files.some((f) => f.action === "wrote"); + const hasSkips = files.some((f) => f.action === "skipped"); + if (hasWrites && hasSkips) return { ok: true, status: "partial" }; + if (hasWrites || files.length === 0) return { ok: true, status: "success" }; + return { ok: true, status: "noop" }; +} + +export function outputError(message: string, json: boolean): void { + if (json) { + const result: CommandResult = { ok: false, status: "error", errors: [message] }; + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } else { + process.stderr.write(`Error: ${message}\n`); + } + process.exitCode = 1; +} diff --git a/src/utils/pr.ts b/src/utils/pr.ts new file mode 100644 index 0000000..166e5c2 --- /dev/null +++ b/src/utils/pr.ts @@ -0,0 +1,83 @@ +/** File patterns that Primer generates and should be included in PRs. */ +export const PRIMER_FILE_PATTERNS = [ + ".github/copilot-instructions.md", + ".vscode/mcp.json", + ".vscode/settings.json", + "AGENTS.md", + "primer.eval.json" +] as const; + +/** Check if a file path is a Primer-generated file. */ +export function isPrimerFile(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, "/"); + return ( + PRIMER_FILE_PATTERNS.some((p) => normalized === p) || normalized.endsWith(".instructions.md") + ); +} + +export function buildInstructionsPrBody(): string { + return [ + "## 🤖 Copilot Instructions Added", + "", + "This PR adds a `.github/copilot-instructions.md` file to help GitHub Copilot understand this codebase better.", + "", + "### What's Included", + "", + "The instructions file contains:", + "- Project overview and architecture", + "- Tech stack and conventions", + "- Build/test commands", + "- Key directories and files", + "", + "### Benefits", + "", + "With these instructions, Copilot will:", + "- Generate more contextually-aware code suggestions", + "- Follow project-specific patterns and conventions", + "- Understand the codebase structure", + "", + "---", + "*Generated by [Primer](https://github.com/pierceboggan/primer) - Prime your repos for AI*" + ].join("\n"); +} + +export function buildFullPrBody(): string { + return [ + "## 🤖 Primed for AI", + "", + "This PR adds configurations to prime this repository for AI coding assistants.", + "", + "### Added Files", + "", + "| File | Purpose |", + "|------|---------|", + "| `.github/copilot-instructions.md` | Custom instructions for GitHub Copilot |", + "| `.vscode/settings.json` | VS Code settings for optimal AI assistance |", + "| `.vscode/mcp.json` | Model Context Protocol server configuration |", + "", + "### What's Included", + "", + "The instructions file contains:", + "- Project overview and architecture", + "- Tech stack and conventions", + "- Build/test commands", + "- Key directories and files", + "", + "### Benefits", + "", + "With these configurations, Copilot will:", + "- Generate more contextually-aware code suggestions", + "- Follow project-specific patterns and conventions", + "- Understand the codebase structure", + "- Have access to MCP tools for enhanced capabilities", + "", + "### How to Use", + "", + "1. Merge this PR", + "2. Open the project in VS Code", + "3. Start chatting with Copilot — it now understands your project!", + "", + "---", + "*Generated by [Primer](https://github.com/pierceboggan/primer) - Prime your repos for AI*" + ].join("\n"); +} diff --git a/src/utils/repo.ts b/src/utils/repo.ts new file mode 100644 index 0000000..8ce621a --- /dev/null +++ b/src/utils/repo.ts @@ -0,0 +1,6 @@ +/** owner/name — rejects . and .. as segments */ +export const GITHUB_REPO_RE = /^(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}$)([a-zA-Z0-9._-]+)$/; + +/** org/project/repo — rejects . and .. as segments */ +export const AZURE_REPO_RE = + /^(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}\/)([a-zA-Z0-9._-]+)\/(?!\.{1,2}$)([a-zA-Z0-9._-]+)$/; diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..9937cc3 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + target: "node20", + outDir: "dist", + clean: true, + splitting: false, + sourcemap: true, + dts: false, + banner: { + js: "#!/usr/bin/env node" + }, + // Keep node_modules as external — they'll be installed via npm + external: [/^[^./]/], + esbuildOptions(options) { + options.jsx = "automatic"; + } +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2481bee --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "html", "json-summary"], + reportsDirectory: "./coverage" + } + } +}); diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore new file mode 100644 index 0000000..b0d82c4 --- /dev/null +++ b/vscode-extension/.vscodeignore @@ -0,0 +1,7 @@ +src/** +node_modules/** +.vscode/** +tsconfig.json +esbuild.mjs +package-lock.json +*.map diff --git a/vscode-extension/LICENSE b/vscode-extension/LICENSE new file mode 100644 index 0000000..cb6db3e --- /dev/null +++ b/vscode-extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Primer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vscode-extension/README.md b/vscode-extension/README.md new file mode 100644 index 0000000..502d0c8 --- /dev/null +++ b/vscode-extension/README.md @@ -0,0 +1,94 @@ +# Primer — AI Repository Setup + +Prime your repositories for AI-assisted development, right from VS Code. + +## Getting Started + +Open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) and search for **Primer** — or click the **Primer** icon in the Activity Bar to start from the sidebar. + +First time? Run **Primer: Get Started** (or open the walkthrough from the Welcome tab) for a guided 5-step setup. + +## Features + +### Analyze Repository + +Detect languages, frameworks, package managers, and monorepo structure. Results populate the **Analysis** tree view in the sidebar. + +`Primer: Analyze Repository` + +### AI Readiness Assessment + +Score your repo across **9 pillars** grouped into **Repo Health** and **AI Setup**, with maturity levels from Functional (1) to Autonomous (5). + +- Interactive HTML report with dark/light theme +- Drill-down into criteria in the **Readiness** tree view +- Pass/fail icons with evidence for each criterion + +`Primer: AI Readiness Report` + +### Generate Instructions + +Create AI instruction files using the Copilot SDK. Choose your format: + +- **copilot-instructions.md** — GitHub Copilot's native format +- **AGENTS.md** — Broader agent format at repo root + +For monorepos, pick specific areas to generate per-area instruction files with `applyTo` scoping. + +`Primer: Generate Copilot Instructions` + +### Generate Configs + +Set up MCP servers (`.vscode/mcp.json`) and VS Code settings (`.vscode/settings.json`) tuned to your project. + +`Primer: Generate Configs` + +### Evaluate Instructions + +Measure how much your instructions improve AI responses by comparing with/without using a judge model. Results display in an interactive viewer inside VS Code. + +`Primer: Run Eval` · `Primer: Scaffold Eval Config` + +### Initialize Repository + +One command to analyze, generate instructions, and create configs: + +`Primer: Initialize Repository` + +### Create Pull Request + +Commit Primer-generated files and open a PR directly from VS Code. Supports both **GitHub** and **Azure DevOps** repositories — the platform is detected automatically from your git remote. + +`Primer: Create Pull Request` + +## Sidebar Views + +The **Primer** Activity Bar icon opens two tree views: + +| View | Contents | +| ------------- | ------------------------------------------------------------------------------------------------- | +| **Analysis** | Languages, frameworks, monorepo areas — with action buttons for instructions and configs | +| **Readiness** | Maturity level, pillar groups (Repo Health / AI Setup), criteria pass/fail with evidence tooltips | + +Both views show welcome screens with action buttons when no data is loaded yet. + +## Settings + +| Setting | Default | Description | +| -------------------- | ------------------- | -------------------------------------------------- | +| `primer.model` | `claude-sonnet-4.5` | Default Copilot model for generation | +| `primer.autoAnalyze` | `false` | Automatically analyze repository on workspace open | + +## Requirements + +- **VS Code 1.100.0+** +- **GitHub Copilot Chat extension** (provides the Copilot CLI) +- **Copilot authentication** — run `copilot` → `/login` in your terminal +- **GitHub account** — for GitHub PR creation (authenticated via VS Code) +- **Microsoft account** _(optional)_ — for Azure DevOps PR creation (authenticated via VS Code) + +## Links + +- [Primer CLI on GitHub](https://github.com/digitarald/primer) +- [Contributing Guide](https://github.com/digitarald/primer/blob/main/CONTRIBUTING.md) +- [License (MIT)](https://github.com/digitarald/primer/blob/main/LICENSE) diff --git a/vscode-extension/esbuild.mjs b/vscode-extension/esbuild.mjs new file mode 100644 index 0000000..bf4e942 --- /dev/null +++ b/vscode-extension/esbuild.mjs @@ -0,0 +1,63 @@ +import * as esbuild from "esbuild"; +import { readFile } from "node:fs/promises"; + +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); + +/** + * esbuild plugin: neutralise the SDK's getBundledCliPath() which calls + * import.meta.resolve("@github/copilot/sdk"). In CJS bundles esbuild replaces + * import.meta with {}, making .resolve undefined and crashing at runtime. + * Primer always passes an explicit cliPath so this function is dead code, but + * the SDK constructor still evaluates it as a default value. + */ +const shimSdkImportMeta = { + name: "shim-sdk-import-meta", + setup(build) { + build.onLoad({ filter: /copilot-sdk[\\/]dist[\\/]client\.js$/ }, async (args) => { + let contents = await readFile(args.path, "utf8"); + // Replace the body of getBundledCliPath with a safe no-op return. + // The function signature and surrounding code stay intact. + contents = contents.replace( + 'const sdkUrl = import.meta.resolve("@github/copilot/sdk");\n const sdkPath = fileURLToPath(sdkUrl);\n return join(dirname(dirname(sdkPath)), "index.js");', + 'return "bundled-cli-unavailable";' + ); + return { contents, loader: "js" }; + }); + } +}; + +/** @type {esbuild.BuildOptions} */ +const buildOptions = { + entryPoints: ["src/extension.ts"], + bundle: true, + format: "cjs", + platform: "node", + target: "node22", + outfile: "out/extension.js", + // Keep Copilot SDK bundled: packaged extensions may not have node_modules at runtime. + external: ["vscode"], + sourcemap: !production, + minify: production, + plugins: [shimSdkImportMeta], + alias: { + // Resolve Primer source imports via the parent src/ directory + primer: "../src" + } +}; + +async function main() { + if (watch) { + const ctx = await esbuild.context(buildOptions); + await ctx.watch(); + console.log("Watching for changes..."); + } else { + await esbuild.build(buildOptions); + console.log(production ? "Production build complete." : "Build complete."); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json new file mode 100644 index 0000000..b69a95d --- /dev/null +++ b/vscode-extension/package-lock.json @@ -0,0 +1,906 @@ +{ + "name": "primer-vscode", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "primer-vscode", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@github/copilot-sdk": "^0.1.24", + "@octokit/rest": "^22.0.1", + "@types/node": "^25.2.3", + "@types/vscode": "^1.109.0", + "esbuild": "^0.27.3", + "typescript": "^5.9.3" + }, + "engines": { + "vscode": "^1.109.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@github/copilot": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", + "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "0.0.411", + "@github/copilot-darwin-x64": "0.0.411", + "@github/copilot-linux-arm64": "0.0.411", + "@github/copilot-linux-x64": "0.0.411", + "@github/copilot-win32-arm64": "0.0.411", + "@github/copilot-win32-x64": "0.0.411" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", + "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", + "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", + "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", + "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.25.tgz", + "integrity": "sha512-hIgYLPXzWw9bNgrsD5BLKmgVH20ow5Or5UyVXfVe3YgeiaTgFxC4jWSAVHLGB6ufHZUrvbjppcq2dWK63FmDRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@github/copilot": "^0.0.411", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", + "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", + "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.3.tgz", + "integrity": "sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..02bd23f --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,300 @@ +{ + "name": "primer-vscode", + "displayName": "Primer — AI Repository Setup", + "description": "Prime repositories for AI-assisted development: generate Copilot instructions, VS Code configs, MCP configs, and evaluate AI agent effectiveness.", + "version": "0.0.2", + "publisher": "digitarald", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/digitarald/primer.git" + }, + "engines": { + "vscode": "^1.109.0" + }, + "categories": [ + "Machine Learning", + "Other" + ], + "keywords": [ + "copilot", + "ai", + "instructions", + "mcp", + "readiness", + "agents" + ], + "activationEvents": [], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "primer.analyze", + "title": "Analyze Repository", + "category": "Primer", + "icon": "$(search)" + }, + { + "command": "primer.generate", + "title": "Generate Configs", + "category": "Primer", + "icon": "$(file-code)" + }, + { + "command": "primer.instructions", + "title": "Generate Copilot Instructions", + "category": "Primer", + "icon": "$(edit)" + }, + { + "command": "primer.readiness", + "title": "AI Readiness Report", + "category": "Primer", + "icon": "$(shield)" + }, + { + "command": "primer.eval", + "title": "Run Eval", + "category": "Primer", + "icon": "$(beaker)" + }, + { + "command": "primer.evalInit", + "title": "Scaffold Eval Config", + "category": "Primer", + "icon": "$(new-file)" + }, + { + "command": "primer.init", + "title": "Initialize Repository", + "category": "Primer", + "icon": "$(rocket)" + }, + { + "command": "primer.pr", + "title": "Create Pull Request", + "category": "Primer", + "icon": "$(git-pull-request)" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "primer", + "title": "Primer", + "icon": "resources/primer.svg" + } + ] + }, + "views": { + "primer": [ + { + "id": "primer.analysis", + "name": "Analysis", + "contextualTitle": "Primer" + }, + { + "id": "primer.readiness", + "name": "Readiness", + "contextualTitle": "Primer" + } + ] + }, + "viewsWelcome": [ + { + "view": "primer.analysis", + "contents": "Discover languages, frameworks, and project structure in your repository.\n[$(search) Analyze Repository](command:primer.analyze)\nNew to Primer? [Get Started](command:workbench.action.openWalkthrough?%5B%22digitarald.primer-vscode%23primer.gettingStarted%22%5D)", + "when": "workspaceFolderCount > 0" + }, + { + "view": "primer.analysis", + "contents": "Open a folder to get started.\n[$(folder-opened) Open Folder](command:vscode.openFolder)", + "when": "workspaceFolderCount == 0" + }, + { + "view": "primer.readiness", + "contents": "Score your repo across 9 AI-readiness pillars — from build systems to custom instructions.\n[$(shield) Run Assessment](command:primer.readiness)", + "when": "workspaceFolderCount > 0" + }, + { + "view": "primer.readiness", + "contents": "Open a folder to get started.\n[$(folder-opened) Open Folder](command:vscode.openFolder)", + "when": "workspaceFolderCount == 0" + } + ], + "walkthroughs": [ + { + "id": "primer.gettingStarted", + "title": "Get Started with Primer", + "description": "Prime your repository for AI-assisted development in 5 steps.", + "steps": [ + { + "id": "analyze", + "title": "Analyze Your Repository", + "description": "Discover languages, frameworks, and project structure.\n[$(search) Analyze Repository](command:primer.analyze)", + "media": { + "markdown": "resources/walkthrough/analyze.md" + }, + "completionEvents": [ + "onCommand:primer.analyze" + ] + }, + { + "id": "instructions", + "title": "Generate AI Instructions", + "description": "Create custom instructions that teach AI about your codebase.\n[$(edit) Generate Instructions](command:primer.instructions)", + "media": { + "markdown": "resources/walkthrough/instructions.md" + }, + "completionEvents": [ + "onCommand:primer.instructions" + ] + }, + { + "id": "configs", + "title": "Generate Configs", + "description": "Set up MCP servers and VS Code settings for AI workflows.\n[$(file-code) Generate Configs](command:primer.generate)", + "media": { + "markdown": "resources/walkthrough/configs.md" + }, + "completionEvents": [ + "onCommand:primer.generate" + ] + }, + { + "id": "readiness", + "title": "Check AI Readiness", + "description": "Score your repo across 9 pillars with a visual maturity report.\n[$(shield) Run Assessment](command:primer.readiness)", + "media": { + "markdown": "resources/walkthrough/readiness.md" + }, + "completionEvents": [ + "onCommand:primer.readiness" + ] + }, + { + "id": "eval", + "title": "Evaluate Effectiveness", + "description": "Measure how much your instructions improve AI responses.\n[$(new-file) Scaffold Eval Config](command:primer.evalInit)\n[$(beaker) Run Eval](command:primer.eval)", + "media": { + "markdown": "resources/walkthrough/eval.md" + }, + "completionEvents": [ + "onCommand:primer.evalInit" + ] + } + ] + } + ], + "configuration": { + "title": "Primer", + "properties": { + "primer.model": { + "type": "string", + "default": "claude-sonnet-4.5", + "description": "Default Copilot model to use for generation." + }, + "primer.autoAnalyze": { + "type": "boolean", + "default": false, + "description": "Automatically analyze repository on workspace open." + }, + "primer.judgeModel": { + "type": "string", + "description": "Copilot model to use for judging eval responses (defaults to primer.model if unset)." + } + } + }, + "menus": { + "commandPalette": [ + { + "command": "primer.analyze", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.generate", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.instructions", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.readiness", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.eval", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.evalInit", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.init", + "when": "workspaceFolderCount > 0" + }, + { + "command": "primer.pr", + "when": "workspaceFolderCount > 0" + } + ], + "view/title": [ + { + "command": "primer.analyze", + "when": "view == primer.analysis", + "group": "navigation@1" + }, + { + "command": "primer.instructions", + "when": "view == primer.analysis && primer.hasAnalysis", + "group": "navigation@2" + }, + { + "command": "primer.generate", + "when": "view == primer.analysis && primer.hasAnalysis", + "group": "1_actions" + }, + { + "command": "primer.init", + "when": "view == primer.analysis", + "group": "2_setup" + }, + { + "command": "primer.readiness", + "when": "view == primer.readiness", + "group": "navigation@1" + }, + { + "command": "primer.eval", + "when": "view == primer.readiness", + "group": "1_eval@1" + }, + { + "command": "primer.evalInit", + "when": "view == primer.readiness", + "group": "1_eval@2" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "node esbuild.mjs --production", + "build": "node esbuild.mjs", + "watch": "node esbuild.mjs --watch", + "typecheck": "tsc --noEmit", + "package": "vsce package" + }, + "extensionDependencies": [ + "vscode.git" + ], + "devDependencies": { + "@github/copilot-sdk": "^0.1.24", + "@octokit/rest": "^22.0.1", + "@types/node": "^25.2.3", + "@types/vscode": "^1.109.0", + "esbuild": "^0.27.3", + "typescript": "^5.9.3" + } +} diff --git a/vscode-extension/resources/primer.svg b/vscode-extension/resources/primer.svg new file mode 100644 index 0000000..24764ee --- /dev/null +++ b/vscode-extension/resources/primer.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/vscode-extension/resources/walkthrough/analyze.md b/vscode-extension/resources/walkthrough/analyze.md new file mode 100644 index 0000000..9084700 --- /dev/null +++ b/vscode-extension/resources/walkthrough/analyze.md @@ -0,0 +1,18 @@ +## Discover Your Repository + +Primer scans your codebase to detect **languages**, **frameworks**, **package managers**, and **monorepo structure**. + +This analysis powers everything else — instructions are tailored to your stack, readiness checks know what to look for, and configs match your tooling. + +### What gets detected + +- Programming languages and their relative usage +- Frameworks (React, Express, Django, Spring, etc.) +- Monorepo workspaces (npm, pnpm, Cargo, Go, .NET, and more) +- Logical areas like frontend, backend, and infrastructure + +### Try it + +Click the button below, or use `Primer: Analyze Repository` from the Command Palette. + +The results appear in the **Primer** sidebar — expand each category to explore. diff --git a/vscode-extension/resources/walkthrough/configs.md b/vscode-extension/resources/walkthrough/configs.md new file mode 100644 index 0000000..eb7c31d --- /dev/null +++ b/vscode-extension/resources/walkthrough/configs.md @@ -0,0 +1,15 @@ +## Configure Your AI Tools + +Generate configuration files that connect AI assistants to your development environment. + +### MCP Config (`.vscode/mcp.json`) + +[Model Context Protocol](https://modelcontextprotocol.io) servers give AI tools access to external data sources — databases, APIs, documentation, and more. Primer detects what's relevant to your stack and generates a starter config. + +### VS Code Settings (`.vscode/settings.json`) + +Recommended editor settings for AI-assisted development, tuned to your project's languages and frameworks. + +### Safe by default + +Primer won't overwrite existing files. If a config already exists, you'll be asked whether to overwrite it. diff --git a/vscode-extension/resources/walkthrough/eval.md b/vscode-extension/resources/walkthrough/eval.md new file mode 100644 index 0000000..d2449df --- /dev/null +++ b/vscode-extension/resources/walkthrough/eval.md @@ -0,0 +1,16 @@ +## Measure the Impact + +How much do your instructions actually help? Primer's **eval framework** answers that question by comparing AI responses with and without your custom instructions. + +### How it works + +1. **Scaffold** — Primer analyzes your codebase and generates test cases (`primer.eval.json`) with questions an AI might answer about your code +2. **Run** — Each test case gets two AI responses: one with instructions, one without +3. **Judge** — A judge model scores which response better follows your codebase conventions +4. **Review** — An interactive HTML viewer shows the comparisons side-by-side with scores + +### Get started + +If you don't have a `primer.eval.json` yet, use **Scaffold Eval Config** to auto-generate test cases from your codebase analysis. + +Then run the eval — results open in an interactive viewer right inside VS Code. diff --git a/vscode-extension/resources/walkthrough/instructions.md b/vscode-extension/resources/walkthrough/instructions.md new file mode 100644 index 0000000..226ab86 --- /dev/null +++ b/vscode-extension/resources/walkthrough/instructions.md @@ -0,0 +1,18 @@ +## Teach AI About Your Code + +Generate custom instruction files that help Copilot (and other AI tools) understand your codebase conventions, architecture, and preferences. + +### Choose your format + +- **copilot-instructions.md** — GitHub Copilot's native format, placed in `.github/` +- **AGENTS.md** — Broader agent instructions at the repo root + +### Monorepo support + +For monorepos, Primer can generate **per-area** instruction files scoped with `applyTo` glob patterns. Each area (frontend, backend, infra) gets its own tailored instructions. + +### How it works + +Primer uses the **Copilot SDK** to analyze your code and generate context-aware instructions. It reads your project structure, conventions, dependencies, and existing documentation to produce instructions that capture what makes your codebase unique. + +Pick your format and areas when prompted — your instructions file opens automatically when done. diff --git a/vscode-extension/resources/walkthrough/readiness.md b/vscode-extension/resources/walkthrough/readiness.md new file mode 100644 index 0000000..80113cb --- /dev/null +++ b/vscode-extension/resources/walkthrough/readiness.md @@ -0,0 +1,30 @@ +## How AI-Ready Is Your Repo? + +Primer evaluates your repository across **9 pillars** organized into two groups: + +### Repo Health + +- **Style & Validation** — linters, formatters, editor configs +- **Build System** — build scripts, CI/CD pipelines +- **Testing** — test frameworks, coverage, test scripts +- **Documentation** — README, CONTRIBUTING, architecture docs +- **Dev Environment** — devcontainers, setup scripts +- **Code Quality** — type checking, static analysis +- **Observability** — logging, monitoring, error tracking +- **Security & Governance** — CODEOWNERS, security policies + +### AI Setup + +- **AI Tooling** — custom instructions, MCP servers, agents, skills + +### Maturity levels + +| Level | Name | Meaning | +| ----- | ------------ | ------------------------------ | +| 1 | Functional | Basic tooling in place | +| 2 | Documented | README and instructions exist | +| 3 | Standardized | CI/CD, policies, CODEOWNERS | +| 4 | Optimized | MCP, agents, skills configured | +| 5 | Autonomous | Full AI-native development | + +Results appear as a **visual HTML report** and in the **Readiness** sidebar with drill-down into each criterion. diff --git a/vscode-extension/src/auth.ts b/vscode-extension/src/auth.ts new file mode 100644 index 0000000..3e9a447 --- /dev/null +++ b/vscode-extension/src/auth.ts @@ -0,0 +1,67 @@ +import * as vscode from "vscode"; + +/** + * Acquires a GitHub token via VS Code's built-in authentication provider. + * Used by SDK-dependent services (instructions, eval) and Octokit (PR creation). + */ +export async function getGitHubToken(): Promise { + const session = await vscode.authentication.getSession("github", ["repo"], { + createIfNone: true + }); + return session.accessToken; +} + +/** Azure DevOps well-known resource ID */ +const AZURE_DEVOPS_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798"; + +/** + * Acquires an Azure DevOps token via VS Code's built-in Microsoft authentication provider. + * Returns a Bearer token (not a PAT). + */ +export async function getAzureDevOpsToken(): Promise { + const session = await vscode.authentication.getSession( + "microsoft", + [`${AZURE_DEVOPS_RESOURCE}/.default`], + { createIfNone: true } + ); + return session.accessToken; +} + +// ── Platform detection ── + +export type GitHubRemote = { owner: string; repo: string }; +export type AzureRemote = { organization: string; project: string; repo: string }; + +// Note: matches github.com only — GitHub Enterprise Server remotes are not supported. +const GITHUB_REMOTE_RE = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/; +const AZURE_HTTPS_RE = /dev\.azure\.com(?::\d+)?\/([^/]+)\/([^/]+)\/_git\/(.+?)(?:\.git)?\/?$/; +const AZURE_SSH_RE = /ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?\/?$/; +const AZURE_LEGACY_RE = /([^./]+)\.visualstudio\.com(?::\d+)?\/([^/]+)\/_git\/(.+?)(?:\.git)?\/?$/; + +export function parseGitHubRemote(url: string): GitHubRemote | null { + const match = url.match(GITHUB_REMOTE_RE); + if (!match) return null; + return { owner: match[1], repo: match[2] }; +} + +export function parseAzureRemote(url: string): AzureRemote | null { + for (const re of [AZURE_HTTPS_RE, AZURE_SSH_RE, AZURE_LEGACY_RE]) { + const match = url.match(re); + if (match) { + return { organization: match[1], project: match[2], repo: match[3] }; + } + } + return null; +} + +export type PlatformInfo = + | { platform: "github"; remote: GitHubRemote } + | { platform: "azure"; remote: AzureRemote }; + +export function detectPlatform(url: string): PlatformInfo | null { + const gh = parseGitHubRemote(url); + if (gh) return { platform: "github", remote: gh }; + const az = parseAzureRemote(url); + if (az) return { platform: "azure", remote: az }; + return null; +} diff --git a/vscode-extension/src/commands/analyze.ts b/vscode-extension/src/commands/analyze.ts new file mode 100644 index 0000000..9417d4c --- /dev/null +++ b/vscode-extension/src/commands/analyze.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode"; +import { analyzeRepo } from "../services.js"; +import type { RepoAnalysis } from "../types.js"; + +let cachedAnalysis: RepoAnalysis | undefined; + +export function getCachedAnalysis(): RepoAnalysis | undefined { + return cachedAnalysis; +} + +export function setCachedAnalysis(analysis: RepoAnalysis | undefined): void { + cachedAnalysis = analysis; + vscode.commands.executeCommand("setContext", "primer.hasAnalysis", !!analysis); +} + +export async function analyzeCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Primer: Analyzing repository…" }, + async () => { + try { + const analysis = await analyzeRepo(workspacePath); + setCachedAnalysis(analysis); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Analysis failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} + +export function getWorkspacePath(): string | undefined { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + vscode.window.showWarningMessage("Primer: No workspace folder open."); + return undefined; + } + return folder.uri.fsPath; +} diff --git a/vscode-extension/src/commands/eval.ts b/vscode-extension/src/commands/eval.ts new file mode 100644 index 0000000..38f3c02 --- /dev/null +++ b/vscode-extension/src/commands/eval.ts @@ -0,0 +1,116 @@ +import * as vscode from "vscode"; +import path from "node:path"; +import { + runEval, + generateEvalScaffold, + analyzeRepo, + safeWriteFile, + DEFAULT_MODEL +} from "../services.js"; +import { VscodeProgressReporter } from "../progress.js"; +import { getWorkspacePath, getCachedAnalysis, setCachedAnalysis } from "./analyze.js"; +import { createWebviewPanel } from "../webview.js"; +import fs from "node:fs"; + +export async function evalCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + const configPath = path.join(workspacePath, "primer.eval.json"); + if (!fs.existsSync(configPath)) { + const action = await vscode.window.showWarningMessage( + "Primer: No primer.eval.json found. Create one?", + "Scaffold", + "Cancel" + ); + if (action === "Scaffold") { + await evalInitCommand(); + } + return; + } + + const config = vscode.workspace.getConfiguration("primer"); + const model = config.get("model") ?? DEFAULT_MODEL; + const judgeModel = config.get("judgeModel") ?? model; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Primer: Running eval…", + cancellable: false + }, + async (progress) => { + try { + const reporter = new VscodeProgressReporter(progress); + + reporter.update("Running evaluation…"); + const result = await runEval({ + configPath, + repoPath: workspacePath, + model, + judgeModel, + onProgress: (msg) => reporter.update(msg) + }); + + reporter.succeed(`Eval complete. ${result.summary}`); + + if (result.viewerPath && fs.existsSync(result.viewerPath)) { + const html = fs.readFileSync(result.viewerPath, "utf-8"); + createWebviewPanel("primer.evalResults", "Eval Results", html); + } + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Eval failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} + +export async function evalInitCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + const config = vscode.workspace.getConfiguration("primer"); + const model = config.get("model"); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Primer: Scaffolding eval config…", + cancellable: false + }, + async (progress) => { + try { + const reporter = new VscodeProgressReporter(progress); + + let analysis = getCachedAnalysis(); + if (!analysis) { + reporter.update("Analyzing repository…"); + analysis = await analyzeRepo(workspacePath); + setCachedAnalysis(analysis); + } + + reporter.update("Generating eval cases…"); + const evalConfig = await generateEvalScaffold({ + repoPath: workspacePath, + count: 5, + model, + areas: analysis.areas, + onProgress: (msg) => reporter.update(msg) + }); + + const outputPath = path.join(workspacePath, "primer.eval.json"); + await safeWriteFile(outputPath, JSON.stringify(evalConfig, null, 2) + "\n", false); + + reporter.succeed("Eval config scaffolded."); + const doc = await vscode.workspace.openTextDocument(outputPath); + await vscode.window.showTextDocument(doc); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Eval scaffold failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} diff --git a/vscode-extension/src/commands/generate.ts b/vscode-extension/src/commands/generate.ts new file mode 100644 index 0000000..e9082b7 --- /dev/null +++ b/vscode-extension/src/commands/generate.ts @@ -0,0 +1,101 @@ +import * as vscode from "vscode"; +import { generateConfigs, analyzeRepo } from "../services.js"; +import { getWorkspacePath, getCachedAnalysis, setCachedAnalysis } from "./analyze.js"; + +const GENERATE_OPTIONS = [ + { label: "MCP Config", value: "mcp", description: ".vscode/mcp.json" }, + { label: "VS Code Settings", value: "vscode", description: ".vscode/settings.json" } +] as const; + +export async function generateCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + const picked = await vscode.window.showQuickPick( + GENERATE_OPTIONS.map((o) => ({ + label: o.label, + description: o.description, + value: o.value, + picked: false + })), + { placeHolder: "Select config type(s) to generate", canPickMany: true } + ); + if (!picked || picked.length === 0) return; + + let analysis = getCachedAnalysis(); + + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Primer: Generating ${picked.map((p) => p.label).join(", ")}…` + }, + async () => { + try { + if (!analysis) { + analysis = await analyzeRepo(workspacePath); + setCachedAnalysis(analysis); + } + + return await generateConfigs({ + repoPath: workspacePath, + analysis, + selections: picked.map((p) => p.value), + force: false + }); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Config generation failed — ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } + } + ); + + if (!result) return; + + const wrote = result.files.filter((f) => f.action === "wrote"); + const skipped = result.files.filter((f) => f.action === "skipped"); + + if (wrote.length > 0) { + const openAction = "Open File"; + const msg = `Generated ${wrote.map((f) => f.path).join(", ")}${skipped.length ? ` (${skipped.length} skipped)` : ""}`; + const action = await vscode.window.showInformationMessage(`Primer: ${msg}`, openAction); + if (action === openAction && wrote[0]) { + const doc = await vscode.workspace.openTextDocument(wrote[0].path); + await vscode.window.showTextDocument(doc); + } + } else if (skipped.length > 0) { + const overwrite = "Overwrite"; + const action = await vscode.window.showWarningMessage( + `Primer: All ${skipped.length} config files already exist.`, + overwrite + ); + if (action === overwrite) { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Primer: Overwriting configs…` + }, + async () => { + try { + const forceResult = await generateConfigs({ + repoPath: workspacePath, + analysis: analysis!, + selections: picked.map((p) => p.value), + force: true + }); + const forceWrote = forceResult.files.filter((f) => f.action === "wrote"); + if (forceWrote.length > 0) { + const doc = await vscode.workspace.openTextDocument(forceWrote[0]!.path); + await vscode.window.showTextDocument(doc); + } + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Config overwrite failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); + } + } +} diff --git a/vscode-extension/src/commands/init.ts b/vscode-extension/src/commands/init.ts new file mode 100644 index 0000000..0c56773 --- /dev/null +++ b/vscode-extension/src/commands/init.ts @@ -0,0 +1,136 @@ +import * as vscode from "vscode"; +import path from "node:path"; +import { + analyzeRepo, + generateConfigs, + generateCopilotInstructions, + safeWriteFile +} from "../services.js"; +import { VscodeProgressReporter } from "../progress.js"; +import { getWorkspacePath, setCachedAnalysis } from "./analyze.js"; + +export async function initCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + const config = vscode.workspace.getConfiguration("primer"); + const model = config.get("model"); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Primer: Initializing repository…", + cancellable: false + }, + async (progress) => { + try { + const reporter = new VscodeProgressReporter(progress); + + reporter.update("Analyzing repository…"); + const analysis = await analyzeRepo(workspacePath); + setCachedAnalysis(analysis); + + reporter.update("Generating Copilot instructions…"); + const instructionsContent = await generateCopilotInstructions({ + repoPath: workspacePath, + model, + onProgress: (msg) => reporter.update(msg) + }); + + let skippedInstructions = false; + + if (instructionsContent.trim()) { + const instructionsPath = path.join(workspacePath, ".github", "copilot-instructions.md"); + const dir = path.dirname(instructionsPath); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(dir)); + const { wrote } = await safeWriteFile(instructionsPath, instructionsContent, false); + if (!wrote) { + skippedInstructions = true; + } + } + + reporter.update("Generating configs…"); + const result = await generateConfigs({ + repoPath: workspacePath, + analysis, + selections: ["mcp", "vscode"], + force: false + }); + + const wrote = result.files.filter((f) => f.action === "wrote"); + const skipped = result.files.filter((f) => f.action === "skipped"); + if (skippedInstructions) + skipped.push({ path: ".github/copilot-instructions.md", action: "skipped" }); + else if (instructionsContent.trim()) + wrote.push({ path: ".github/copilot-instructions.md", action: "wrote" }); + + if (wrote.length === 0 && skipped.length > 0) { + reporter.succeed("All files already exist."); + const overwrite = "Overwrite"; + const action = await vscode.window.showWarningMessage( + `Primer: All ${skipped.length} files already exist.`, + overwrite + ); + if (action === overwrite) { + try { + reporter.update("Overwriting configs…"); + + if (instructionsContent.trim()) { + const instrPath = path.join(workspacePath, ".github", "copilot-instructions.md"); + await safeWriteFile(instrPath, instructionsContent, true); + } + + await generateConfigs({ + repoPath: workspacePath, + analysis, + selections: ["mcp", "vscode"], + force: true + }); + reporter.succeed("Configs overwritten."); + + const instructionsPath = path.join( + workspacePath, + ".github", + "copilot-instructions.md" + ); + try { + const doc = await vscode.workspace.openTextDocument(instructionsPath); + await vscode.window.showTextDocument(doc); + } catch { + // File may not exist if generation was skipped + } + + vscode.window.showInformationMessage(`Primer: ${skipped.length} files overwritten.`); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Config overwrite failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + return; + } + + const parts: string[] = []; + if (wrote.length) parts.push(`${wrote.length} files generated`); + if (skipped.length) parts.push(`${skipped.length} skipped (already exist)`); + + reporter.succeed("Repository initialized."); + + const instructionsPath = path.join(workspacePath, ".github", "copilot-instructions.md"); + try { + const doc = await vscode.workspace.openTextDocument(instructionsPath); + await vscode.window.showTextDocument(doc); + } catch { + // File may not exist if generation was skipped + } + + vscode.window.showInformationMessage(`Primer: ${parts.join(", ") || "Done."}`); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Initialization failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} diff --git a/vscode-extension/src/commands/instructions.ts b/vscode-extension/src/commands/instructions.ts new file mode 100644 index 0000000..484f57d --- /dev/null +++ b/vscode-extension/src/commands/instructions.ts @@ -0,0 +1,163 @@ +import * as vscode from "vscode"; +import path from "node:path"; +import { + generateCopilotInstructions, + generateAreaInstructions, + writeAreaInstruction, + safeWriteFile, + analyzeRepo +} from "../services.js"; +import { VscodeProgressReporter } from "../progress.js"; +import { getWorkspacePath, getCachedAnalysis, setCachedAnalysis } from "./analyze.js"; + +const FORMAT_OPTIONS = [ + { + label: "$(file) copilot-instructions.md", + description: ".github/copilot-instructions.md", + value: "copilot-instructions" as const, + relativePath: path.join(".github", "copilot-instructions.md") + }, + { + label: "$(robot) AGENTS.md", + description: "AGENTS.md at repo root", + value: "agents-md" as const, + relativePath: "AGENTS.md" + } +]; + +export async function instructionsCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + const model = vscode.workspace.getConfiguration("primer").get("model"); + + // Pick format + const formatPick = await vscode.window.showQuickPick(FORMAT_OPTIONS, { + placeHolder: "Choose instruction format" + }); + if (!formatPick) return; + + // Ensure analysis is available before starting progress + let analysis = getCachedAnalysis(); + if (!analysis) { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Primer: Analyzing repository…" }, + async () => { + analysis = await analyzeRepo(workspacePath); + setCachedAnalysis(analysis!); + } + ); + } + if (!analysis) return; + + // Collect area selections before starting generation progress + let selectedAreas: typeof analysis.areas = undefined; + if (analysis.areas && analysis.areas.length > 0) { + const picked = await vscode.window.showQuickPick( + analysis.areas.map((a) => ({ label: a.name, description: a.description, area: a })), + { placeHolder: "Select areas for instructions (or Escape for root only)", canPickMany: true } + ); + if (picked && picked.length > 0) { + selectedAreas = picked.map((p) => p.area); + } + } + + const instructionFile = path.join(workspacePath, formatPick.relativePath); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Primer: Generating ${formatPick.relativePath}…`, + cancellable: false + }, + async (progress) => { + try { + const reporter = new VscodeProgressReporter(progress); + + reporter.update("Generating root instructions…"); + const content = await generateCopilotInstructions({ + repoPath: workspacePath, + model, + onProgress: (msg) => reporter.update(msg) + }); + + let rootSkipped = false; + if (content.trim()) { + const dir = path.dirname(instructionFile); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(dir)); + const { wrote } = await safeWriteFile(instructionFile, content, false); + if (!wrote) rootSkipped = true; + } + + let areasSkipped = 0; + const areaBodies = new Map(); + if (selectedAreas) { + for (const area of selectedAreas) { + reporter.update(`Generating instructions for ${area.name}…`); + const body = await generateAreaInstructions({ + repoPath: workspacePath, + area, + model, + onProgress: (msg) => reporter.update(msg) + }); + areaBodies.set(area.name, body); + if (body.trim()) { + const result = await writeAreaInstruction(workspacePath, area, body, false); + if (result.status === "skipped") areasSkipped++; + } + } + } + + const totalSkipped = (rootSkipped ? 1 : 0) + areasSkipped; + const areasWithContent = selectedAreas + ? selectedAreas.filter((a) => (areaBodies.get(a.name) ?? "").trim()).length + : 0; + const totalFiles = (content.trim() ? 1 : 0) + areasWithContent; + + if (totalSkipped > 0 && totalSkipped === totalFiles) { + reporter.succeed("All instruction files already exist."); + const overwrite = "Overwrite"; + const action = await vscode.window.showWarningMessage( + `Primer: All ${totalSkipped} instruction files already exist.`, + overwrite + ); + if (action === overwrite) { + try { + reporter.update("Overwriting instructions…"); + if (content.trim()) { + await safeWriteFile(instructionFile, content, true); + } + if (selectedAreas) { + for (const area of selectedAreas) { + const body = areaBodies.get(area.name) ?? ""; + if (body.trim()) { + await writeAreaInstruction(workspacePath, area, body, true); + } + } + } + reporter.succeed("Instructions overwritten."); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Instruction overwrite failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } else { + reporter.succeed("Instructions generated."); + } + + // Open the generated file + try { + const doc = await vscode.workspace.openTextDocument(instructionFile); + await vscode.window.showTextDocument(doc); + } catch { + // File may not exist if generation produced no content + } + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Instruction generation failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} diff --git a/vscode-extension/src/commands/pr.ts b/vscode-extension/src/commands/pr.ts new file mode 100644 index 0000000..5c87b23 --- /dev/null +++ b/vscode-extension/src/commands/pr.ts @@ -0,0 +1,184 @@ +import * as vscode from "vscode"; +import { + createPullRequest, + createAzurePullRequest, + getAzureDevOpsRepo, + isPrimerFile +} from "../services.js"; +import { getGitHubToken, getAzureDevOpsToken, detectPlatform, type PlatformInfo } from "../auth.js"; +import { getWorkspacePath } from "./analyze.js"; +import { getGitRepository } from "../gitUtils.js"; + +export async function prCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + const repository = getGitRepository(workspacePath); + if (!repository) return; + + // Detect remote owner/repo + const origin = repository.state.remotes.find((r) => r.name === "origin"); + const originUrl = origin?.pushUrl ?? origin?.fetchUrl; + if (!originUrl) { + vscode.window.showErrorMessage("Primer: No origin remote found."); + return; + } + + const detected = detectPlatform(originUrl); + if (!detected) { + vscode.window.showErrorMessage( + "Primer: Unsupported remote. GitHub and Azure DevOps are supported." + ); + return; + } + + const branch = repository.state.HEAD?.name; + if (!branch) { + vscode.window.showErrorMessage("Primer: Could not determine current branch (detached HEAD?)."); + return; + } + + // Detect default branch by checking remote refs for origin/main or origin/master + const refs = await repository.getRefs({ pattern: "refs/remotes/origin/*" }); + const hasMain = refs.some((r) => r.name === "origin/main"); + const hasMaster = refs.some((r) => r.name === "origin/master"); + const base = hasMain ? "main" : hasMaster ? "master" : "main"; + + if (branch === base) { + vscode.window.showErrorMessage( + "Primer: Cannot create PR from the default branch. Check out a feature branch first." + ); + return; + } + + const title = await vscode.window.showInputBox({ + prompt: "Pull request title", + value: `Add Primer AI configs for ${detected.remote.repo}` + }); + if (!title) return; + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Primer: Creating pull request…" }, + async () => { + try { + // Stage, commit, and push Primer files (shared logic for both platforms) + const aborted = await stageAndPush(repository, branch, title); + if (aborted) return; + + // Guard against empty PRs when branch has no diff from base + const baseRefs = await repository.getRefs({ pattern: `refs/remotes/origin/${base}` }); + const headRefs = await repository.getRefs({ pattern: `refs/remotes/origin/${branch}` }); + if ( + baseRefs[0]?.commit && + headRefs[0]?.commit && + baseRefs[0].commit === headRefs[0].commit + ) { + const proceed = await vscode.window.showWarningMessage( + "Primer: No new changes detected. The PR may be empty.", + "Continue", + "Cancel" + ); + if (proceed !== "Continue") return; + } + + const prUrl = await createPR(detected, branch, base, title); + + const openAction = "Open in Browser"; + const action = await vscode.window.showInformationMessage( + `Primer: Pull request created.`, + openAction + ); + if (action === openAction && prUrl.startsWith("https://")) { + vscode.env.openExternal(vscode.Uri.parse(prUrl)); + } + } catch (err) { + vscode.window.showErrorMessage( + `Primer: PR creation failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} + +// ── Helpers ── + +/** Stage Primer files, commit, and push. Returns true if the user aborted. */ +async function stageAndPush( + repository: NonNullable>, + branch: string, + title: string +): Promise { + const allChanges = [...repository.state.workingTreeChanges, ...repository.state.indexChanges]; + const seen = new Set(); + const changes = allChanges.filter((c) => { + const key = c.uri.toString(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (changes.length > 0) { + const primerChanges = changes.filter((c) => + isPrimerFile(vscode.workspace.asRelativePath(c.uri, false)) + ); + + if (primerChanges.length === 0) { + vscode.window.showWarningMessage("Primer: No Primer-generated files to commit."); + return true; + } + + const stagedNonPrimer = repository.state.indexChanges.filter( + (c) => !isPrimerFile(vscode.workspace.asRelativePath(c.uri, false)) + ); + if (stagedNonPrimer.length > 0) { + const proceed = await vscode.window.showWarningMessage( + `Primer: ${stagedNonPrimer.length} non-Primer file(s) are already staged and will be included in the commit.`, + "Continue", + "Cancel" + ); + if (proceed !== "Continue") return true; + } + + await repository.add(primerChanges.map((c) => c.uri)); + await repository.commit(title); + } + + await repository.push("origin", branch, true); + return false; +} + +async function createPR( + info: PlatformInfo, + branch: string, + base: string, + title: string +): Promise { + if (info.platform === "azure") { + const { organization, project, repo } = info.remote; + const token = await getAzureDevOpsToken(); + const repoInfo = await getAzureDevOpsRepo(token, organization, project, repo, "bearer"); + return createAzurePullRequest({ + token, + organization, + project, + repoId: repoInfo.id, + repoName: repoInfo.name, + title, + body: "Generated by Primer VS Code extension.", + sourceBranch: branch, + targetBranch: base, + authMode: "bearer" + }); + } + + const { owner, repo } = info.remote; + const token = await getGitHubToken(); + return createPullRequest({ + token, + owner, + repo, + title, + body: "Generated by Primer VS Code extension.", + head: branch, + base + }); +} diff --git a/vscode-extension/src/commands/readiness.ts b/vscode-extension/src/commands/readiness.ts new file mode 100644 index 0000000..a4c0684 --- /dev/null +++ b/vscode-extension/src/commands/readiness.ts @@ -0,0 +1,49 @@ +import * as vscode from "vscode"; +import { runReadinessReport, generateVisualReport, analyzeRepo } from "../services.js"; +import { VscodeProgressReporter } from "../progress.js"; +import { getWorkspacePath, getCachedAnalysis, setCachedAnalysis } from "./analyze.js"; +import { createWebviewPanel } from "../webview.js"; +import { readinessTreeProvider } from "../views/providers.js"; + +export async function readinessCommand(): Promise { + const workspacePath = getWorkspacePath(); + if (!workspacePath) return; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Primer: Running readiness assessment…", + cancellable: false + }, + async (progress) => { + try { + const reporter = new VscodeProgressReporter(progress); + + let analysis = getCachedAnalysis(); + if (!analysis) { + reporter.update("Analyzing repository…"); + analysis = await analyzeRepo(workspacePath); + setCachedAnalysis(analysis); + } + + reporter.update("Evaluating readiness pillars…"); + const report = await runReadinessReport({ repoPath: workspacePath }); + + reporter.update("Generating report…"); + const repoName = vscode.workspace.workspaceFolders?.[0]?.name ?? "Repository"; + const html = generateVisualReport({ + reports: [{ repo: repoName, report }], + title: `${repoName} — AI Readiness` + }); + + createWebviewPanel("primer.readinessReport", "AI Readiness Report", html); + readinessTreeProvider.setReport(report); + reporter.succeed(`Readiness: Level ${report.achievedLevel} achieved.`); + } catch (err) { + vscode.window.showErrorMessage( + `Primer: Readiness assessment failed — ${err instanceof Error ? err.message : String(err)}` + ); + } + } + ); +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 0000000..a6276c9 --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,100 @@ +import * as vscode from "vscode"; +import { analyzeCommand, getCachedAnalysis } from "./commands/analyze.js"; +import { generateCommand } from "./commands/generate.js"; +import { instructionsCommand } from "./commands/instructions.js"; +import { readinessCommand } from "./commands/readiness.js"; +import { evalCommand, evalInitCommand } from "./commands/eval.js"; +import { initCommand } from "./commands/init.js"; +import { prCommand } from "./commands/pr.js"; +import { analysisTreeProvider, readinessTreeProvider } from "./views/providers.js"; + +export function activate(context: vscode.ExtensionContext): void { + // Status bar — only show after analysis + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + statusBar.text = "$(beaker) Primer"; + statusBar.tooltip = "Primer — click to analyze repository"; + statusBar.command = "primer.analyze"; + context.subscriptions.push(statusBar); + + // Tree views (createTreeView for description/badge support) + const analysisView = vscode.window.createTreeView("primer.analysis", { + treeDataProvider: analysisTreeProvider + }); + const readinessView = vscode.window.createTreeView("primer.readiness", { + treeDataProvider: readinessTreeProvider + }); + context.subscriptions.push(analysisView, readinessView); + + function updateAnalysisView(): void { + const analysis = getCachedAnalysis(); + if (analysis) { + const parts = [...analysis.languages.slice(0, 3), ...analysis.frameworks.slice(0, 2)]; + analysisView.description = parts.join(", ") || undefined; + } else { + analysisView.description = undefined; + } + } + + function updateReadinessView(): void { + const report = readinessTreeProvider.getReport(); + if (report) { + readinessView.description = `Level ${report.achievedLevel}`; + } else { + readinessView.description = undefined; + } + } + + function updateStatusBar(): void { + const analysis = getCachedAnalysis(); + if (analysis) { + const parts = analysis.languages.slice(0, 2); + statusBar.text = `$(beaker) ${parts.join(", ") || "Primer"}`; + statusBar.tooltip = `Primer — ${analysis.languages.join(", ")}${analysis.isMonorepo ? " | monorepo" : ""}`; + statusBar.show(); + } else { + statusBar.hide(); + } + } + + // Commands + context.subscriptions.push( + vscode.commands.registerCommand("primer.analyze", async () => { + await analyzeCommand(); + analysisTreeProvider.refresh(); + updateAnalysisView(); + updateStatusBar(); + vscode.commands.executeCommand("primer.analysis.focus"); + }), + vscode.commands.registerCommand("primer.generate", generateCommand), + vscode.commands.registerCommand("primer.instructions", instructionsCommand), + vscode.commands.registerCommand("primer.readiness", async () => { + await readinessCommand(); + updateReadinessView(); + updateStatusBar(); + }), + vscode.commands.registerCommand("primer.eval", evalCommand), + vscode.commands.registerCommand("primer.evalInit", evalInitCommand), + vscode.commands.registerCommand("primer.init", async () => { + await initCommand(); + analysisTreeProvider.refresh(); + updateAnalysisView(); + updateStatusBar(); + vscode.commands.executeCommand("primer.analysis.focus"); + }), + vscode.commands.registerCommand("primer.pr", prCommand) + ); + + // Auto-analyze on activation if configured + const config = vscode.workspace.getConfiguration("primer"); + if (config.get("autoAnalyze") && vscode.workspace.workspaceFolders?.length) { + analyzeCommand() + .then(() => { + analysisTreeProvider.refresh(); + updateAnalysisView(); + updateStatusBar(); + }) + .catch((err) => console.error("Primer auto-analyze failed:", err)); + } +} + +export function deactivate(): void {} diff --git a/vscode-extension/src/git.d.ts b/vscode-extension/src/git.d.ts new file mode 100644 index 0000000..3cb28d4 --- /dev/null +++ b/vscode-extension/src/git.d.ts @@ -0,0 +1,59 @@ +/** + * Vendored subset of VS Code's built-in git extension API types. + * Source: https://github.com/microsoft/vscode/blob/main/extensions/git/src/api/git.d.ts + * + * Only the interfaces used by the Primer extension are included. + */ + +import { Uri } from "vscode"; + +export interface GitExtension { + getAPI(version: 1): API; +} + +export interface API { + readonly repositories: Repository[]; +} + +export interface Repository { + readonly rootUri: Uri; + readonly state: RepositoryState; + add(resources: Uri[]): Promise; + commit(message: string, opts?: CommitOptions): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise; + getRefs(query: RefQuery): Promise; +} + +export interface RefQuery { + readonly pattern?: string; +} + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly remotes: Remote[]; + readonly workingTreeChanges: Change[]; + readonly indexChanges: Change[]; +} + +export interface Branch { + readonly name?: string; +} + +export interface Ref { + readonly name?: string; + readonly commit?: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; +} + +export interface Change { + readonly uri: Uri; +} + +export interface CommitOptions { + all?: boolean | "tracked"; +} diff --git a/vscode-extension/src/gitUtils.ts b/vscode-extension/src/gitUtils.ts new file mode 100644 index 0000000..1223274 --- /dev/null +++ b/vscode-extension/src/gitUtils.ts @@ -0,0 +1,28 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { API, GitExtension, Repository } from "./git.js"; + +export function getGitRepository(workspacePath: string): Repository | undefined { + const gitExtension = vscode.extensions.getExtension("vscode.git"); + if (!gitExtension?.isActive) { + vscode.window.showErrorMessage("Primer: Git extension is not available."); + return undefined; + } + + const api: API = gitExtension.exports.getAPI(1); + const workspaceUri = vscode.Uri.file(workspacePath); + // Find the deepest repo whose root is a prefix of (or equal to) the workspace path + const repository = api.repositories + .filter((r) => { + const root = r.rootUri.fsPath; + return workspaceUri.fsPath === root || workspaceUri.fsPath.startsWith(root + path.sep); + }) + .sort((a, b) => b.rootUri.fsPath.length - a.rootUri.fsPath.length)[0]; + + if (!repository) { + vscode.window.showErrorMessage("Primer: No git repository found in the workspace."); + return undefined; + } + + return repository; +} diff --git a/vscode-extension/src/progress.ts b/vscode-extension/src/progress.ts new file mode 100644 index 0000000..a3a63d7 --- /dev/null +++ b/vscode-extension/src/progress.ts @@ -0,0 +1,27 @@ +import type * as vscode from "vscode"; +import type { ProgressReporter } from "primer/utils/output.js"; + +/** + * Adapts VS Code's `Progress<{ message, increment }>` to Primer's `ProgressReporter` interface. + */ +export class VscodeProgressReporter implements ProgressReporter { + constructor( + private readonly progress: vscode.Progress<{ message?: string; increment?: number }> + ) {} + + update(message: string): void { + this.progress.report({ message }); + } + + succeed(message: string): void { + this.progress.report({ message: `✓ ${message}` }); + } + + fail(message: string): void { + this.progress.report({ message: `✗ ${message}` }); + } + + done(): void { + // VS Code progress auto-closes when the withProgress callback resolves + } +} diff --git a/vscode-extension/src/services.ts b/vscode-extension/src/services.ts new file mode 100644 index 0000000..3ab518f --- /dev/null +++ b/vscode-extension/src/services.ts @@ -0,0 +1,19 @@ +export { analyzeRepo } from "primer/services/analyzer.js"; +export { generateConfigs } from "primer/services/generator.js"; +export { + generateCopilotInstructions, + generateAreaInstructions, + writeAreaInstruction +} from "primer/services/instructions.js"; +export { runEval } from "primer/services/evaluator.js"; +export { generateEvalScaffold } from "primer/services/evalScaffold.js"; +export { runReadinessReport, groupPillars } from "primer/services/readiness.js"; +export { generateVisualReport } from "primer/services/visualReport.js"; +export { createPullRequest } from "primer/services/github.js"; +export { + createPullRequest as createAzurePullRequest, + getRepo as getAzureDevOpsRepo +} from "primer/services/azureDevops.js"; +export { isPrimerFile } from "primer/utils/pr.js"; +export { safeWriteFile } from "primer/utils/fs.js"; +export { DEFAULT_MODEL } from "primer/config.js"; diff --git a/vscode-extension/src/types.ts b/vscode-extension/src/types.ts new file mode 100644 index 0000000..79e3f35 --- /dev/null +++ b/vscode-extension/src/types.ts @@ -0,0 +1,10 @@ +/** + * Re-export Primer CLI types for use in the extension. + */ +export type { RepoAnalysis } from "primer/services/analyzer.js"; + +export type { + ReadinessReport, + ReadinessPillarSummary, + ReadinessCriterionResult +} from "primer/services/readiness.js"; diff --git a/vscode-extension/src/views/AnalysisTreeProvider.ts b/vscode-extension/src/views/AnalysisTreeProvider.ts new file mode 100644 index 0000000..1477c7a --- /dev/null +++ b/vscode-extension/src/views/AnalysisTreeProvider.ts @@ -0,0 +1,104 @@ +import * as vscode from "vscode"; +import type { RepoAnalysis } from "../types.js"; +import { getCachedAnalysis } from "../commands/analyze.js"; + +export class AnalysisTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: AnalysisItem): vscode.TreeItem { + return element; + } + + getChildren(element?: AnalysisItem): AnalysisItem[] { + if (element) return element.children ?? []; + const analysis = getCachedAnalysis(); + if (!analysis) return []; + return this.getRootItems(analysis); + } + + private getRootItems(analysis: RepoAnalysis): AnalysisItem[] { + const items: AnalysisItem[] = []; + + if (analysis.languages.length > 0) { + const langs = new AnalysisItem( + "Languages", + vscode.TreeItemCollapsibleState.Expanded, + analysis.languages.map((l) => { + const item = new AnalysisItem(l, vscode.TreeItemCollapsibleState.None); + item.contextValue = "language"; + return item; + }) + ); + langs.iconPath = new vscode.ThemeIcon("code"); + langs.description = `${analysis.languages.length}`; + langs.contextValue = "category"; + items.push(langs); + } + + if (analysis.frameworks.length > 0) { + const frameworks = new AnalysisItem( + "Frameworks", + vscode.TreeItemCollapsibleState.Expanded, + analysis.frameworks.map((f) => { + const item = new AnalysisItem(f, vscode.TreeItemCollapsibleState.None); + item.contextValue = "framework"; + return item; + }) + ); + frameworks.iconPath = new vscode.ThemeIcon("extensions"); + frameworks.description = `${analysis.frameworks.length}`; + frameworks.contextValue = "category"; + items.push(frameworks); + } + + if (analysis.areas && analysis.areas.length > 0) { + const areas = new AnalysisItem( + analysis.isMonorepo ? "Monorepo" : "Areas", + vscode.TreeItemCollapsibleState.Expanded, + analysis.areas.map((a) => { + const item = new AnalysisItem(a.name, vscode.TreeItemCollapsibleState.None); + item.description = typeof a.applyTo === "string" ? a.applyTo : a.applyTo.join(", "); + item.iconPath = new vscode.ThemeIcon("folder"); + item.contextValue = "area"; + const md = new vscode.MarkdownString(); + md.appendMarkdown(`**${a.name}**`); + if (a.description) md.appendMarkdown(`\n\n${a.description}`); + md.appendMarkdown( + `\n\nGlobs: \`${typeof a.applyTo === "string" ? a.applyTo : a.applyTo.join("\`, \`")}\`` + ); + item.tooltip = md; + return item; + }) + ); + areas.iconPath = new vscode.ThemeIcon("folder-library"); + areas.description = analysis.workspaceType ?? undefined; + areas.contextValue = "category"; + items.push(areas); + } + + if (analysis.packageManager) { + const pm = new AnalysisItem("Package Manager", vscode.TreeItemCollapsibleState.None); + pm.description = analysis.packageManager; + pm.iconPath = new vscode.ThemeIcon("package"); + pm.contextValue = "info"; + items.push(pm); + } + + return items; + } +} + +class AnalysisItem extends vscode.TreeItem { + constructor( + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + public readonly children?: AnalysisItem[] + ) { + super(label, collapsibleState); + } +} diff --git a/vscode-extension/src/views/ReadinessTreeProvider.ts b/vscode-extension/src/views/ReadinessTreeProvider.ts new file mode 100644 index 0000000..56b764d --- /dev/null +++ b/vscode-extension/src/views/ReadinessTreeProvider.ts @@ -0,0 +1,145 @@ +import * as vscode from "vscode"; +import type { + ReadinessReport, + ReadinessPillarSummary, + ReadinessCriterionResult +} from "../types.js"; +import { groupPillars } from "../services.js"; + +export class ReadinessTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private report: ReadinessReport | undefined; + + setReport(report: ReadinessReport): void { + this.report = report; + this._onDidChangeTreeData.fire(undefined); + } + + getReport(): ReadinessReport | undefined { + return this.report; + } + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: ReadinessItem): vscode.TreeItem { + return element; + } + + getChildren(element?: ReadinessItem): ReadinessItem[] { + if (element) return element.children ?? []; + if (!this.report) return []; + return this.getRootItems(this.report); + } + + private getRootItems(report: ReadinessReport): ReadinessItem[] { + const items: ReadinessItem[] = []; + + const levelInfo = report.levels.find((l) => l.level === report.achievedLevel); + const level = new ReadinessItem( + levelInfo?.name ?? `Level ${report.achievedLevel}`, + vscode.TreeItemCollapsibleState.None + ); + level.description = `Level ${report.achievedLevel}`; + level.iconPath = new vscode.ThemeIcon( + report.achievedLevel >= 3 ? "pass" : "warning", + new vscode.ThemeColor( + report.achievedLevel >= 3 ? "testing.iconPassed" : "problemsWarningIcon.foreground" + ) + ); + level.contextValue = "level"; + items.push(level); + + const groups = groupPillars(report.pillars); + for (const { label, pillars } of groups) { + const groupChildren = pillars.map((pillar) => { + const criteria = report.criteria.filter((c) => c.pillar === pillar.id); + return this.createPillarItem(pillar, criteria); + }); + + const groupPassed = pillars.reduce((sum, p) => sum + p.passed, 0); + const groupTotal = pillars.reduce((sum, p) => sum + p.total, 0); + const groupPct = groupTotal > 0 ? Math.round((groupPassed / groupTotal) * 100) : 0; + + const groupItem = new ReadinessItem( + label, + vscode.TreeItemCollapsibleState.Expanded, + groupChildren + ); + groupItem.iconPath = new vscode.ThemeIcon( + groupPct === 100 ? "pass" : groupPct >= 50 ? "warning" : "error", + groupPct === 100 + ? new vscode.ThemeColor("testing.iconPassed") + : groupPct >= 50 + ? new vscode.ThemeColor("problemsWarningIcon.foreground") + : new vscode.ThemeColor("testing.iconFailed") + ); + groupItem.description = `${groupPassed}/${groupTotal} (${groupPct}%)`; + groupItem.contextValue = "pillarGroup"; + items.push(groupItem); + } + + return items; + } + + private createPillarItem( + pillar: ReadinessPillarSummary, + criteria: ReadinessCriterionResult[] + ): ReadinessItem { + const pct = Math.round(pillar.passRate * 100); + const item = new ReadinessItem( + pillar.name, + criteria.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + criteria.map((c) => { + const ci = new ReadinessItem(c.title, vscode.TreeItemCollapsibleState.None); + ci.iconPath = new vscode.ThemeIcon( + c.status === "pass" ? "pass" : c.status === "fail" ? "error" : "circle-outline", + c.status === "pass" + ? new vscode.ThemeColor("testing.iconPassed") + : c.status === "fail" + ? new vscode.ThemeColor("testing.iconFailed") + : undefined + ); + ci.description = c.reason; + ci.contextValue = `criterion.${c.status}`; + const md = new vscode.MarkdownString(); + md.appendMarkdown(`**${c.title}**\n\n`); + if (c.reason) md.appendMarkdown(`${c.reason}\n\n`); + if (c.evidence && c.evidence.length > 0) { + md.appendMarkdown("**Evidence:**\n"); + for (const e of c.evidence) { + md.appendMarkdown(`- ${e}\n`); + } + } + ci.tooltip = md; + return ci; + }) + ); + item.iconPath = new vscode.ThemeIcon( + pct === 100 ? "pass" : pct >= 50 ? "warning" : "error", + pct === 100 + ? new vscode.ThemeColor("testing.iconPassed") + : pct >= 50 + ? new vscode.ThemeColor("problemsWarningIcon.foreground") + : new vscode.ThemeColor("testing.iconFailed") + ); + item.description = `${pillar.passed}/${pillar.total} (${pct}%)`; + item.contextValue = "pillar"; + return item; + } +} + +class ReadinessItem extends vscode.TreeItem { + constructor( + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + public readonly children?: ReadinessItem[] + ) { + super(label, collapsibleState); + } +} diff --git a/vscode-extension/src/views/providers.ts b/vscode-extension/src/views/providers.ts new file mode 100644 index 0000000..68eef61 --- /dev/null +++ b/vscode-extension/src/views/providers.ts @@ -0,0 +1,5 @@ +import { AnalysisTreeProvider } from "./AnalysisTreeProvider.js"; +import { ReadinessTreeProvider } from "./ReadinessTreeProvider.js"; + +export const analysisTreeProvider = new AnalysisTreeProvider(); +export const readinessTreeProvider = new ReadinessTreeProvider(); diff --git a/vscode-extension/src/webview.ts b/vscode-extension/src/webview.ts new file mode 100644 index 0000000..6a8e514 --- /dev/null +++ b/vscode-extension/src/webview.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode"; + +const panels = new Map(); + +/** + * Create or reuse a webview panel to display HTML content. + */ +export function createWebviewPanel(id: string, title: string, html: string): vscode.WebviewPanel { + const existing = panels.get(id); + if (existing) { + existing.webview.html = html; + existing.reveal(); + return existing; + } + + const panel = vscode.window.createWebviewPanel(id, title, vscode.ViewColumn.One, { + enableScripts: true, + localResourceRoots: [] + }); + + panel.webview.html = html; + panel.onDidDispose(() => panels.delete(id)); + panels.set(id, panel); + return panel; +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 0000000..30c487b --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "rootDir": "..", + "paths": { + "primer/*": ["../src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "out"] +}