diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dc18c0d..900be70 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,3 +16,9 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..471d0e5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + schedule: + # Weekly Sunday 06:00 UTC + - cron: "0 6 * * 0" + +jobs: + analyze: + name: Analyze JavaScript/TypeScript + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/lean4-ci.yml b/.github/workflows/lean4-ci.yml index fc545fb..8a91306 100644 --- a/.github/workflows/lean4-ci.yml +++ b/.github/workflows/lean4-ci.yml @@ -6,6 +6,7 @@ on: - "specs/**" - "lakefile.lean" - "lean-toolchain" + - "src/lean4-generator.ts" - ".github/workflows/lean4-ci.yml" push: branches: [main, master] @@ -13,6 +14,7 @@ on: - "specs/**" - "lakefile.lean" - "lean-toolchain" + - "src/lean4-generator.ts" jobs: lean4-specs: @@ -31,6 +33,44 @@ jobs: auto-config: "true" test: "false" + - name: Report sorry count + env: + # Fail the job only when SPECSYNC_PROOF_GATE=strict (repo variable or secret). + SPECSYNC_PROOF_GATE: ${{ vars.SPECSYNC_PROOF_GATE || 'soft' }} + run: | + set -euo pipefail + if [ ! -d specs ]; then + echo "No specs/ directory — nothing to report" + exit 0 + fi + # Count sorry tokens outside comments (block /- -/ and line --). + # Note: avoid embedding nested /- -/ examples inside docs (breaks strip). + TOTAL=$(python3 - <<'PY' +import re, pathlib +text = "" +for p in pathlib.Path("specs").rglob("*.lean"): + text += p.read_text(encoding="utf-8", errors="ignore") + "\n" +# Remove block comments iteratively so nested markers in docs do not leak tokens. +prev = None +while prev != text: + prev = text + text = re.sub(r"/-.*?-/", " ", text, count=1, flags=re.S) +stripped = re.sub(r"--[^\n]*", " ", text) +print(len(re.findall(r"(?:^|[^A-Za-z0-9_])sorry(?=[^A-Za-z0-9_]|$)", stripped))) +PY +) + TOTAL=${TOTAL:-0} + echo "SpecSync Lean sorry count: ${TOTAL}" + echo "sorry_count=${TOTAL}" >> "$GITHUB_STEP_SUMMARY" + echo "### SpecSync Lean \`sorry\` report" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Found **${TOTAL}** \`sorry\` occurrence(s) under \`specs/\`." >> "$GITHUB_STEP_SUMMARY" + echo "Gate: \`SPECSYNC_PROOF_GATE=${SPECSYNC_PROOF_GATE}\` (fail only when \`strict\`)." >> "$GITHUB_STEP_SUMMARY" + if [ "$SPECSYNC_PROOF_GATE" = "strict" ] && [ "$TOTAL" -gt 0 ]; then + echo "SPECSYNC_PROOF_GATE=strict and sorry found — failing" + exit 1 + fi + - name: Comment on PR with build summary if: github.event_name == 'pull_request' && success() uses: actions/github-script@v7 diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 7549d68..2e573bd 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -36,5 +36,54 @@ jobs: - name: Build run: npm run build - - name: Test - run: npm test + - name: Test with coverage gate + run: npm run test:coverage + + - name: Production dependency audit + run: npm audit --omit=dev --audit-level=high + + docker-smoke: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t specsync:ci . + + - name: Smoke — image has compiled entrypoint + run: | + docker run --rm --entrypoint node specsync:ci -e "require('fs').accessSync('dist/index.js')" + + vscode-extension: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: vscode-extension + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: vscode-extension/package-lock.json + + - name: Install extension deps + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm install + fi + + - name: Compile extension + run: npm run compile diff --git a/README.md b/README.md index 79c424c..9544682 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ **Formal specs, meet your pull requests.** -A GitHub App that turns diffs into structured specification suggestions—powered by LLMs, grounded in AST analysis, and wired for Lean 4 when you want proofs to compile. +A GitHub App that turns PR diffs into structured specification suggestions—powered by LLMs when configured, grounded in tree-sitter AST analysis, and able to write Lean 4 **spec-as-contract** modules under `.specsync/` (opaque stubs + closed proofs where possible; labeled unfinished goals otherwise — not claimed complete). [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) @@ -29,21 +29,23 @@ A GitHub App that turns diffs into structured specification suggestions—powere ## What it does -| Capability | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------ | -| **Event-driven** | Listens on GitHub for pull requests, pushes, and comments—then runs the analysis pipeline. | -| **Multi-language diffs** | Parses changes across common languages using Tree-sitter–backed extraction. | -| **LLM-assisted specs** | Proposes preconditions, postconditions, invariants, and rationale where API keys are configured. | -| **Lean-ready output** | Generated modules can target your Lake `specs/` tree so `lake build` stays honest. | -| **Operator-friendly** | Health and readiness routes on the Probot router, Docker image, and CI for Node + Lean. | +| Capability | Description | +| --- | --- | +| **Async PR analysis** | Webhooks enqueue work on an in-process `AnalysisJobQueue` (idempotent per PR head SHA) so GitHub gets a fast ack. Optional `SPECSYNC_JOB_MARKERS_DIR` shares completion markers across instances on a volume (not committed to the target repo). | +| **Multi-language diffs** | Tree-sitter AST for JavaScript, TypeScript, Python, Java, and Rust (no Go/C/C++ parsers). Review comments use `RIGHT` for adds/modifies and `LEFT` for pure deletions. | +| **LLM-assisted specs** | Proposes preconditions, postconditions, invariants, and rationale when API keys are set. Production denies silent mock output unless `SPECSYNC_ALLOW_MOCK_LLM=true`. | +| **`.specsync/` store** | `/specsync accept` commits JSON contracts (and Lean stubs) under `.specsync/` on the PR branch. Mutating commands require write AuthZ and are rate-limited per actor/PR. | +| **Soft proof gate** | Default `SPECSYNC_PROOF_GATE=soft`: Lean CI **reports** `sorry` count (comments stripped). Set `strict` to **fail** on any remaining `sorry` in `specs/`. Closed fragments use real proofs (`trivial`, `Nat.zero_le`); unfinished LLM obligations stay labeled — this is not end-to-end formal verification. | +| **Drift on push** | Compares changed functions against accepted `.specsync/` specs on main/master pushes. | +| **Operator-friendly** | `GET /health`, `GET /ready` (includes job + metrics counters), optional secret-gated dashboard (loopback by default), Docker image, Node + Lean + CodeQL + VS Code extension CI. | -The optional [VS Code extension](vscode-extension/) adds editor-side commands and local proof hooks; the server-side app lives in this repository’s `src/` tree. +The optional [VS Code extension](vscode-extension/) reads the same `.specsync/` store; compile it with `npm run compile` in that folder. --- ## Architecture -At a glance: GitHub sends webhooks to **Probot**; the app parses diffs, walks ASTs, asks the **spec analyzer** (and **LLM client** when configured), then posts back via the **GitHub UI** layer. Lean artifacts and CI are first-class paths, not an afterthought. +GitHub webhooks hit **Probot**; handlers enqueue analysis, then the worker parses diffs, walks ASTs, calls the **spec analyzer** / **LLM client**, and posts review comments plus coverage checks. Accepted specs live under **`.specsync/`**; Lake-built Lean sources for this repo also live under [`specs/`](specs/). ```mermaid flowchart TB @@ -51,6 +53,7 @@ flowchart TB E[Webhooks] end subgraph pipeline [Analysis] + Q[AnalysisJobQueue] D[Diff parser] T[AST extractor] N[Spec analyzer] @@ -58,31 +61,33 @@ flowchart TB end subgraph surface [Surfaces] G[PR comments and checks] - K[Lean specs under specs/] - S[Slack and dashboards] + S[".specsync/ store"] + K[Lean stubs / Lake specs] end - E --> D --> T --> N + E --> Q --> D --> T --> N N --> L N --> G - N --> K - N --> S + G --> S + S --> K ``` -Formal modules consumed by Lake live under [`specs/`](specs/), with [`lakefile.lean`](lakefile.lean) and [`lean-toolchain`](lean-toolchain) pinning the toolchain. +Formal modules consumed by Lake live under [`specs/`](specs/), with [`lakefile.lean`](lakefile.lean) and [`lean-toolchain`](lean-toolchain) pinning the toolchain. Generated accept-path Lean may also land under `.specsync/lean/` depending on configuration. --- ## Repository layout -| Location | Role | -| ------------------------------------------ | -------------------------------------------------------------------------------- | -| [`src/index.ts`](src/index.ts) | Probot entry: webhooks, `GET /health`, `GET /ready` when the router is available | -| [`src/`](src/) | Application modules—parser, analyzer, GitHub UI, LLM client, generators | -| [`dist/`](dist/) | Build output from `npm run build` (generated, not committed) | -| [`specs/`](specs/) | Lean 4 sources for `lake build` | -| [`test/`](test/) | Jest tests (`*.test.ts`) | -| [`vscode-extension/`](vscode-extension/) | Editor extension (TypeScript → `out/`) | -| [`.github/workflows/`](.github/workflows/) | Continuous integration | +| Location | Role | +| --- | --- | +| [`src/index.ts`](src/index.ts) | Probot entry: webhooks, queue, `GET /health`, `GET /ready` | +| [`src/analysis-queue.ts`](src/analysis-queue.ts) | In-process async job runner with idempotency | +| [`src/spec-store.ts`](src/spec-store.ts) | `.specsync/` read/write helpers | +| [`src/`](src/) | Parser, analyzer, GitHub UI, LLM client, generators, metrics | +| [`dist/`](dist/) | Build output from `npm run build` (generated, not committed) | +| [`specs/`](specs/) | Lean 4 sources for `lake build` | +| [`test/`](test/) | Jest tests (`*.test.ts`) | +| [`vscode-extension/`](vscode-extension/) | Editor extension (`npm run compile`) | +| [`.github/workflows/`](.github/workflows/) | Node CI, Lean CI, CodeQL | --- @@ -126,35 +131,24 @@ Optional but common: ```env OPENAI_API_KEY=... ANTHROPIC_API_KEY=... -GITHUB_TOKEN=... +GITHUB_TOKEN=... # CLI/demo only; PR path uses installation octokit SPECS_DIR=specs PORT=3000 +SPECSYNC_ALLOW_MOCK_LLM=false +SPECSYNC_PROOF_GATE=soft +SPECSYNC_MAX_SPECS_PER_PR=10 +SPECSYNC_JOB_MARKERS_DIR= # optional shared FS for multi-instance job idempotency +SPECSYNC_COMMAND_RATE_LIMIT=10 +SPECSYNC_COMMAND_RATE_WINDOW_MS=60000 +SPECSYNC_DASHBOARD=0 +SPECSYNC_DASHBOARD_SECRET= ``` +See [`env.example`](env.example) and [`app.yml`](app.yml) for the full App permission set (`checks: write`, `contents: write` for accept commits, etc.). + ### GitHub App manifest (reference) -Use this shape when registering the app; point the webhook URL at your deployed Probot instance. - -```yaml -name: SpecSync -description: GitHub-native specification assistant -url: https://github.com/your-org/specsync -hook_attributes: - url: https://your-domain.com/webhook - content_type: json -default_permissions: - contents: read - pull_requests: write - issues: write - metadata: read -default_events: - - pull_request - - push - - issues - - issue_comment - - pull_request_review - - pull_request_review_comment -``` +Use [`app.yml`](app.yml) when registering the app; point the webhook URL at your deployed Probot instance. Replace org/domain placeholders with your deployment. --- @@ -180,7 +174,7 @@ default_events: When the Probot server exposes a router, the app registers: - `GET /health` — liveness -- `GET /ready` — readiness (extend when you add databases or queues) +- `GET /ready` — readiness, including analysis-queue stats and in-process metrics counters The [`Dockerfile`](Dockerfile) healthcheck expects `GET /health` on the process port (default `3000`). @@ -188,20 +182,27 @@ The [`Dockerfile`](Dockerfile) healthcheck expects `GET /health` on the process ## Lean and Lake -- **CI:** [`lean4-ci.yml`](.github/workflows/lean4-ci.yml) runs `lake build` via [lean-action](https://github.com/leanprover/lean-action) when Lean-related paths change. -- **Local:** From the repo root, `lake build` after installing Elan/Lean. -- **Generator contract:** Output from [`src/lean4-generator.ts`](src/lean4-generator.ts) should respect `SPECS_DIR` (default `specs`) so files sit in the same Lake library as [`lakefile.lean`](lakefile.lean). +- **CI:** [`lean4-ci.yml`](.github/workflows/lean4-ci.yml) runs `lake build` via [lean-action](https://github.com/leanprover/lean-action) when Lean-related paths or `src/lean4-generator.ts` change. +- **Local:** From the repo root, `lake build` after installing Elan/Lean (see `lean-toolchain`). +- **Committed baseline:** [`specs/Specs.lean`](specs/Specs.lean) + [`specs/Specs/ClosedNatContract.lean`](specs/Specs/ClosedNatContract.lean) are Std-only and **contain zero `sorry`** (opaque + closed proofs). +- **Generator contract:** [`src/lean4-generator.ts`](src/lean4-generator.ts) emits Std-only Lake modules: + - Function under contract → `opaque` (no unfinished proof for missing bodies) + - Smoke / Nat nonneg / other decidable fragments → real proofs (`trivial`, `Nat.zero_le`, …) + - Undecided LLM postconditions → `/- SpecSync: unproved: -/` + `sorry` only +- **Proof gate:** `SPECSYNC_PROOF_GATE=soft` (default) reports counts; `strict` fails if any `sorry` remains. SpecSync does **not** claim completed formal verification while open obligations exist. +- **Accept path:** Artifacts for target repos are stored under `.specsync/` (separate from this repo’s `specs/` library). --- ## Continuous integration -| Workflow | Purpose | -| ------------------------------------------------ | ------------------------------------------ | -| [`node-ci.yml`](.github/workflows/node-ci.yml) | Lint, format check, typecheck, build, test | -| [`lean4-ci.yml`](.github/workflows/lean4-ci.yml) | `lake build` for Lean specs | +| Workflow | Purpose | +| --- | --- | +| [`node-ci.yml`](.github/workflows/node-ci.yml) | Lint, format, typecheck, build, coverage tests, `npm audit --production`, Docker smoke, vscode-extension `compile` | +| [`lean4-ci.yml`](.github/workflows/lean4-ci.yml) | `lake build` for Lean specs | +| [`codeql.yml`](.github/workflows/codeql.yml) | CodeQL analysis for JavaScript/TypeScript | -[`dependabot.yml`](.github/dependabot.yml) schedules weekly npm updates for the root package and [`vscode-extension/`](vscode-extension/). +[`dependabot.yml`](.github/dependabot.yml) schedules weekly npm and GitHub Actions updates. --- @@ -217,10 +218,12 @@ docker run -p 3000:3000 \ -e APP_ID= \ -e PRIVATE_KEY= \ -e WEBHOOK_SECRET= \ + -e NODE_ENV=production \ + -e SPECSYNC_ALLOW_MOCK_LLM=false \ specsync ``` -Add LLM keys the same way if you use live models in production. +Add LLM keys the same way if you use live models in production. Without keys (and without `SPECSYNC_ALLOW_MOCK_LLM=true`), the app posts an LLM-unavailable notice instead of fake specs. ### Other platforms @@ -230,17 +233,18 @@ Any environment that provides Node, `PORT`, Probot env vars, and a **public HTTP ## Usage notes +- **Accept / ignore / edit:** Comment `/specsync accept` (or ignore/edit) on a suggestion; AuthZ requires a collaborator (or stronger) or PR committer. Accept writes `.specsync/specs/` + Lean stubs on the PR branch. - **Demos:** After `npm run build`, run `npm run demo` or `npm run ui-demo`. -- **VS Code:** See [`vscode-extension/README.md`](vscode-extension/README.md) for building the extension (`npx tsc -p .` inside that folder). -- **Lean:** Modules under `specs/` should pass `lake build`; proof sketches may use `sorry` until completed. +- **VS Code:** See [`vscode-extension/README.md`](vscode-extension/README.md); CI runs `npm run compile` in that package. +- **Lean:** Committed modules under `specs/` pass `lake build` with zero `sorry`. Accept-path sketches may still use labeled unfinished proofs until a human (or stronger autoformalization) closes them. --- ## Quality and security -- Pull requests run lint, format, typecheck, build, and tests; Lean CI validates the Lake project. -- Prefer Dependabot PRs and periodic `npm audit` before releases. -- Probot uses structured logging; see [`src/health.ts`](src/health.ts) for optional standalone health HTTP. +- PRs run lint, format, typecheck, build, coverage-gated tests, production `npm audit`, Docker image smoke, and extension compile; CodeQL runs on push/PR/schedule. +- Structured logs (Probot `context.log` / shared pino) include job id, PR, latency, and LLM mock|live soft-cost fields where available. +- Prefer Dependabot PRs; treat audit failures as blocking unless an exception is documented in the workflow. --- diff --git a/app.yml b/app.yml index 0f9a7f1..6d577ce 100644 --- a/app.yml +++ b/app.yml @@ -1,17 +1,25 @@ # GitHub App manifest template (see https://docs.github.com/en/apps/creating-github-apps) # Register or update your app in GitHub settings; align webhook URL with your Probot deployment. # Full setup: README.md "GitHub App manifest (reference)" and https://probot.github.io/ +# +# Placeholders (replace before registering the app): +# APP_HOMEPAGE_URL — public homepage for the app (e.g. https://github.com//SpecSync) +# WEBHOOK_URL — Probot webhook endpoint (e.g. https:///webhook) +# These are documented here only; GitHub App registration uses concrete URLs, not env var expansion. name: SpecSync description: A GitHub-native, frictionless specification system -url: https://github.com/your-org/specsync +url: https://github.com//SpecSync # APP_HOMEPAGE_URL hook_attributes: - url: https://your-domain.com/webhook + url: https:///webhook # WEBHOOK_URL content_type: json default_permissions: - contents: read + # contents:write — accept flow commits specs under .specsync/ on the PR branch + contents: write pull_requests: write issues: write + # checks:write — coverage / ProofCheck check runs + checks: write metadata: read default_events: - pull_request diff --git a/docs/audits/REPO_AUDIT.md b/docs/audits/REPO_AUDIT.md new file mode 100644 index 0000000..f128674 --- /dev/null +++ b/docs/audits/REPO_AUDIT.md @@ -0,0 +1,310 @@ +# SpecSync Repository Audit + +**Date:** 2026-07-16 +**Scope:** Full repository at `C:\Users\mateo\SpecSync` (src, tests, CI, Lean, VS Code extension, infra) +**Method:** Static code review + marker search + structure/CI/test inventory (no live deploy, no paid LLM calls) +**Companion canvas:** `.cursor/projects/.../canvases/repo-audit.canvas.tsx` (open beside chat) + +--- + +## Executive summary + +**Verdict: prototype / architecture demo — not production-ready.** + +SpecSync has a credible skeleton: a Probot app that on PR events fetches a diff, heuristically finds changed functions, optionally enriches with Tree-sitter AST, calls OpenAI/Anthropic (or a **mock**), and posts GitHub review comments plus a coverage check. That critical path is real but **fragile** (wrong Octokit client, confidence scale bugs, bad line anchors, sync LLM in the webhook). + +Around that path sits a large surface of **stubs, mock dashboards, unwired generators, and a VS Code extension that does not compile**. Spec persistence, drift detection, Lean proof gating, Sigstore, and truthful coverage are missing or faked. Roughly **26+ discrete stub/placeholder surfaces** (plus pervasive `sorry` in the Lean generator and mock data generators). Maturity is early R&D / demo — suitable for local demos with keys, not for a multi-tenant production GitHub App. + +--- + +## Inventory of stubs / placeholders + +| Location | Symbol / area | What's missing | +|----------|---------------|----------------| +| `src/spec-analyzer.ts` | `detectDrift` | Placeholder; always `[]`; logs "not yet implemented" | +| `src/spec-analyzer.ts` | `storeSpec` / `retrieveSpec` | `console.log` only; retrieve always `null` | +| `src/spec-analyzer.ts` | `generateSpecSuggestion` | Comment still says "placeholder"; LLM is wired | +| `src/llm-client.ts` | `generateMockSpec` | Silent mock when no API keys or on API failure | +| `src/index.ts` | `proofStatus` | Hardcoded `coverage: 75`, fabricated proof status | +| `src/index.ts` | `void vscodeUI/webDashboard/uiPrinciples` | Constructed, never started | +| `src/index.ts` | `handlePush` | Voids `diffParser`/`astExtractor`; drift never fires | +| `src/ci-integration.ts` | `signFile` | Fake `sigstore:${sha256}`; not called from entry | +| `src/ci-integration.ts` | module | Entire CI/Lean runner unwired from `index.ts` | +| `src/lean4-generator.ts` | theorems / tactics | Heavy `sorry` / `-- TODO`; not wired to accept | +| `src/web-dashboard.ts` | `generateMockCoverageData` | No real coverage store | +| `src/web-dashboard.ts` | `generateMockDriftData` | Demo drift | +| `src/web-dashboard.ts` | `generateMockStoriesData` | Demo stories | +| `src/web-dashboard.ts` | `generateMockAuditData` | Demo audit | +| `src/dashboard-ui.ts` | drift APIs/pages | Hardcoded zeros | +| `src/github-ui.ts` | `storeAcceptedSpec` | Delegates to stub `storeSpec` | +| `src/github-ui.ts` | `generateEditForm` | Posts to `/specsync/edit` — no route | +| `src/github-ui.ts` | accept/ignore | Misuses `pulls.dismissReview` | +| `src/ui-principles.ts` | confidence color coding | Validator: "not implemented" | +| `src/health.ts` | `HealthChecker` | Unused; Probot `/ready` has no dependency checks | +| `src/developer-feedback.ts` | module | Demo/Slack queue; unwired | +| `src/vscode-ui.ts` | module | Code generator, not the shipped extension | +| `src/spec-coverage.ts` | module | Never used for PR coverage check | +| `env.example` | `DATABASE_URL` | "Future use" — no persistence | +| `app.yml` | urls / permissions | `your-org` placeholders; no `checks: write` | +| `specs/Specs.lean` | `specsync_smoke` | Only `True := trivial` | +| `vscode-extension/` | compile + features | Sources fail `tsc`; orphan modules; incomplete handlers | + +**Approx count:** ~26 inventory rows above; **~40+** if counting individual `sorry` emission sites, mock generators, and dead command handlers separately. **~20 prioritized findings** in the canvas (5 P0, 11 P1, 4 P2). + +--- + +## Architecture assessment + +### Intended shape + +``` +GitHub webhook → Probot (index.ts) + → DiffParser → ASTExtractor → SpecAnalyzer (+ LLMClient) + → GitHubUI (comments + checks) + → (optional) SlackAlerts, Lean, dashboards, VS Code +``` + +### Reality + +| Layer | Status | +|-------|--------| +| Webhook ingress | Partial — PR + comment + push registered | +| Diff / AST | Prototype — regex + tree-sitter; Go/C/C++ claimed without parsers | +| LLM specs | Works with keys; silent mock without | +| GitHub comments | Partially wired; auth + line + confidence bugs | +| Spec persistence | Stub | +| Lean / proofs / CI gate | Generator + CI wrapper exist; **disconnected** from webhooks | +| Drift | Stub | +| Dashboards | Demo / mock; voided in entry | +| VS Code | Separate package **broken**; `src/vscode-ui.ts` is a generator | +| Observability | Minimal (`context.log` + unused pino `health.ts`) | +| Multi-tenancy | Broken by static `GITHUB_TOKEN` Octokit in `GitHubUI` | + +### Coupling / dead weight + +- God-handler `handlePullRequest` does sync I/O + LLM with no queue. +- Duplicate systems: `WebDashboard` vs `DashboardUI`; `SpecAnalyzer.handleCommentCommand` vs `GitHubUI.handleSpecCommentAction`. +- **16/18** `src` files use `// @ts-nocheck` (only `index.ts` and `health.ts` typed). + +### Critical path gaps (PR → comment) + +1. Wrong Octokit (`GITHUB_TOKEN` vs `context.octokit`) +2. Confidence 0–100 vs 0–1 display bug +3. Line number = hunk index, not file line +4. Coverage check fabricated +5. Accept does not persist or emit Lean +6. No dedupe on PR synchronize → comment spam +7. No `listFiles` pagination + +--- + +## Quality / test / CI gaps + +### Tests + +- **~101 Jest tests** across 8 suites; strong on LLM mock client, Slack formatting, UI principles, Lean string generation, GitHubUI formatting. +- **0% coverage** on: `index.ts`, `ast-extractor.ts`, `ci-integration.ts`, `spec-coverage.ts`, `health.ts`, dashboards, `developer-feedback.ts`. +- `spec-analyzer.ts` ~12% (mostly via accept → `storeSpec`). +- Global mocks in `test/setup.ts` neutralize tree-sitter, fs-extra, simple-git — AST never exercised for real. +- `llm-integration.test.ts` is mock-only (misleading name). +- Webhook fixture `test/fixtures/pull_request.opened.json` only checked for JSON shape — **not** wired to handlers. +- Helpers `createMockContext` / `createMockDiff` defined but unused. + +### CI + +| Workflow | Runs | Gaps | +|----------|------|------| +| `node-ci.yml` | lint, prettier, typecheck, build, `npm test` | No `test:coverage` (50% thresholds unused), no audit/CodeQL, no Docker, no extension job | +| `lean4-ci.yml` | `lake build` on Lean paths | No `sorry` gate; skips when only generator TS changes; no Mathlib | +| Dependabot | npm root + extension | No `github-actions` ecosystem | + +### VS Code extension + +- Sources fail TypeScript compile (broken template literals / strings). +- No `@types/vscode` / scripts / `engines.vscode` in `package.json`. +- Orphan `suggestion-panel.ts` / `proof-runner.ts` with duplicate `activate`; view not in `contributes`. +- `editSpec` / cancel proof incomplete. + +### Lean + +- Committed: smoke theorem only. +- Generator: Mathlib imports + `sorry`; Lake has **no Mathlib** (`packages: []`). + +--- + +## Performance and cost concerns + +| Issue | Severity | Where | +|-------|----------|-------| +| Sync webhook runs full LLM chain | P0 | `src/index.ts` `handlePullRequest` | +| Sequential LLM per changed function, unbounded | P0/P1 | `spec-analyzer.analyzeChanges` | +| No per-PR token/cost budget | P1 | `llm-client.ts` (`max_tokens: 2000` per call) | +| N+1 `repos.getContent` per function | P1 | `ast-extractor.ts` | +| N+1 `createReviewComment` | P1 | `index.ts` loop | +| `listFiles` without pagination | P1 | `index.ts` | +| Full FS walk on dashboard APIs | P1 | `spec-coverage.ts` (if ever started) | +| In-memory `specCache` no TTL / multi-instance | P2 | `spec-analyzer.ts` | + +**Cost sketch:** 20-function PR → up to 20× model calls with ~2k output tokens each, no batching, no “changed lines only” hard cap beyond heuristics — expensive and slow under load. + +--- + +## Security and reliability gaps + +| Issue | Severity | Where | +|-------|----------|-------| +| Static `GITHUB_TOKEN` parallel to App installation auth | P0 | `index.ts`, `github-ui.ts` | +| No authZ on `/specsync` commands | P1 | `index.ts` comment handlers | +| Unauthenticated dashboard APIs (if started) | P1 | `web-dashboard.ts`, `dashboard-ui.ts` | +| Fake Sigstore presented as signing | P1 | `ci-integration.signFile` | +| Mock LLM specs may post to real PRs | P1 | `llm-client.ts` | +| `exec(\`lean --json ${specFile}\`)` shell interpolation | P2 | `ci-integration.ts`, `spec-coverage.ts` | +| Missing `checks: write` in `app.yml` | P1 | Check runs fail in real App | +| HTML form interpolation without escaping | P2 | `github-ui.generateEditForm` | +| `/ready` always ready | P2 | No dependency probes | +| No idempotency → duplicate comments on retries | P1 | PR synchronize + webhook retries | + +Not a pentest — high-level code review only. + +--- + +## Missing vs best-in-class SpecSync-like product + +A best-in-class “specs meet PRs” product would typically include: + +1. **Multi-tenant GitHub App auth** with installation-scoped Octokit only +2. **Durable spec store** (repo files and/or DB) with versioning and review history +3. **Async analysis workers** with budgets, caching, and dedupe +4. **Proof CI** that gates merges on real Lean/Lake outcomes (or explicit “suggest-only” mode) +5. **Drift detection** comparing code changes to stored contracts +6. **Policy / repo config** (languages, thresholds, allowlists) +7. **AuthZ** for accept/edit (CODEOWNERS / role) +8. **Working IDE extension** reading the same store +9. **Observability**: structured logs, metrics (LLM cost, latency), tracing +10. **E2E / contract tests** for webhooks and GitHub API shapes +11. **Honest UX**: never post mock specs or fake coverage as merge signals + +SpecSync today has **partial (1–4 as sketches)** and **none complete**. + +--- + +## Prioritized backlog + +### P0 — unblock a truthful pilot + +| Item | Rationale | +|------|-----------| +| Use `context.octokit` for all GitHub writes; stop initializing `GitHubUI` with `GITHUB_TOKEN` for PR path | App installs cannot work reliably otherwise | +| Normalize confidence to 0–1 (or fix formatter + `index` thresholds together) | Comments are wrong in production | +| Map review comment `line` to GitHub file line (diff position API or proper mapping) | Comments fail or misattach | +| Async job queue for analysis; acknowledge webhook quickly; idempotent runs | Timeouts, cost, duplicate posts | +| Real `storeSpec` + accept path writes Lean/spec artifact; fix dismiss API misuse | “Accept” is currently a no-op / broken | + +### P1 — make signals honest and shippable + +| Item | Rationale | +|------|-----------| +| Remove hardcoded coverage **or** wire `SpecCoverageTracker` / Lean CI | Fake merge signal is worse than none | +| Implement `detectDrift` or delete push/Slack drift path | Dead code that pretends to work | +| Add `checks: write`; paginate files; cap LLM calls | Reliability + cost | +| Refuse to post when using mock LLM in non-dev | Avoid poisoning PRs | +| Probot integration tests for `handlePullRequest`; enforce `test:coverage` in CI | Regressions on critical path | +| Fix VS Code extension compile; wire or delete orphans | Product claim in README | +| Align Lean generator with Lake (Std-only or add Mathlib); CI `sorry` policy | Formal verification story | + +### P2 — harden for production ops + +| Item | Rationale | +|------|-----------| +| Strip `@ts-nocheck`; tighten ESLint | Safety net | +| AuthZ for commands; auth for dashboards or remove them | Abuse / data leak | +| `npm audit` / CodeQL / Docker smoke / extension CI | Supply chain + deploy confidence | +| Structured logging + metrics + real `/ready` | Operability | +| Dependabot for GitHub Actions | Stale Actions risk | + +--- + +## Suggested order of attack (next sessions) + +1. **Session 1 — PR comment correctness:** Octokit + confidence + line numbers. +2. **Session 2 — Scale/cost:** async jobs, budgets, pagination, dedupe. +3. **Session 3 — Accept loop:** persistence + Lean/spec file write. +4. **Session 4 — Honest checks:** real coverage or remove; fix `app.yml`. +5. **Session 5 — Tests/CI:** Probot integration tests + coverage gate. +6. **Session 6 — Drift:** implement or delete; stop fake Slack path. +7. **Session 7 — Lean stack:** Lake/generator alignment + sorry policy. +8. **Session 8 — Extension:** compile fix or quarantine from README claims. +9. **Session 9 — Security/DX:** authZ, types, dashboard truth-or-delete. +10. **Session 10 — Ops:** security scans, Docker, observability. + +--- + +## Inspection limits / blockers + +- No live GitHub App installation or webhook delivery exercised. +- No live OpenAI/Anthropic calls. +- Docker image not built; `lake build` not re-run in this audit pass (Lean CI exists; contents reviewed statically). +- VS Code `tsc` failure reported via explore pass — re-confirm with `npx tsc -p vscode-extension`. +- Runtime behavior of tree-sitter natives under Docker not validated. + +--- + +## Post-remediation status + +**Updated:** 2026-07-16 (after Phases 0–10 + Lean sorry-reduction pass) + +Phases 0–10 of `audit_remediation_plan_5ae97077` are marked complete. The critical path is no longer the prototype described in the executive summary above; treat the inventory table as historical. + +### Lean `sorry` status (honest) + +| Surface | Status | +|---------|--------| +| Committed `specs/` (`Specs.lean`, `Specs/ClosedNatContract.lean`) | **0 `sorry`**; `lake build` green (Std-only) | +| `src/lean4-generator.ts` | Spec-as-contract: `opaque` bodies; smoke / Nat nonneg closed with real proofs; LLM-undecided posts keep `/- SpecSync: unproved: -/` + `sorry` | +| `SPECSYNC_PROOF_GATE` | `soft` reports count; `strict` fails on any `sorry` (comments stripped) | +| Mathlib / full autoformalization | **Still deferred** — not claimed | + +### Done (remediated) + +| Area | Status | +|------|--------| +| Confidence 0–1 + comment formatting | Done | +| Installation-scoped `context.octokit` on PR path | Done | +| Diff hunk → file line mapping; LEFT for pure deletions | Done | +| Async `AnalysisJobQueue` + idempotency; optional FS job markers | Done | +| `.specsync/` accept / ignore / edit apply + Lean write | Done | +| Real coverage checks; honest digests (no fake Sigstore claims) | Done | +| `detectDrift` on push; Slack only when findings exist | Done | +| Mock LLM policy; language support honesty (JS/TS/Python/Java/Rust) | Done | +| Probot integration tests; CI `test:coverage`; CodeQL; Docker; extension compile job | Done | +| VS Code extension compiles; reads `.specsync/`; generator `vscode-ui.ts` removed | Done | +| Single secret-gated dashboard; comment AuthZ; command rate limits | Done | +| `@ts-nocheck` stripped from critical path; metrics + `/ready` | Done | +| README / `env.example` / `app.yml` aligned with behavior | Done | + +### Intentionally deferred + +| Item | Notes | +|------|-------| +| Multi-tenant Postgres SaaS / billing | `DATABASE_URL` documented as future only | +| Real Sigstore/cosign keyless signing | Digests remain honest content hashes | +| Closing all Lean `sorry` / Mathlib autoformalization | **Partial progress:** committed `specs/` is sorry-free; generator minimizes sorry to undecided LLM props. Full autoformalization + Mathlib still deferred | +| Redis/BullMQ workers | Queue interface shaped for swap; in-process + optional markers for now | +| Full multi-region HA / durable queue | Single-instance default; markers need shared volume | + +### Suggested next session (material remaining) + +1. Live install smoke: open a PR with adds + pure deletions; confirm LEFT/RIGHT anchors and accept commits. +2. Raise coverage on `ci-integration.ts` / `spec-coverage.ts` if those modules stay in the product path. +3. If running >1 Probot replica, deploy with a shared `SPECSYNC_JOB_MARKERS_DIR` (or swap in Redis). +4. Optional: wire dashboard to live drift history beyond empty/`.specsync` snapshots. + +--- + +## File index (primary) + +- Entry: `src/index.ts` +- Pipeline: `diff-parser.ts`, `ast-extractor.ts`, `spec-analyzer.ts`, `llm-client.ts`, `github-ui.ts`, `analysis-queue.ts`, `spec-store.ts`, `command-rate-limit.ts` +- Formal/CI: `lean4-generator.ts`, `ci-integration.ts`, `spec-coverage.ts`, `specs/`, `lakefile.lean` +- Surfaces: `web-dashboard.ts`, `slack-alerts.ts`, `vscode-extension/` +- Quality: `test/`, `.github/workflows/`, `jest.config.js`, `Dockerfile`, `app.yml`, `env.example` diff --git a/env.example b/env.example index 465972c..584d994 100644 --- a/env.example +++ b/env.example @@ -9,15 +9,48 @@ APP_ID= PRIVATE_KEY= WEBHOOK_SECRET= -# Optional: PAT or installation token when the app needs extra API access +# Optional: PAT for local CLI/demo only. Production PR path uses context.octokit +# (GitHub App installation auth). Do not rely on GITHUB_TOKEN for multi-tenant installs. GITHUB_TOKEN= -# --- LLM providers (optional; mock/fallback if unset) --- +# --- LLM providers --- +# Without keys, SpecSync falls back to mock specs in development only. +# In production (NODE_ENV=production), mock specs are NOT posted unless +# SPECSYNC_ALLOW_MOCK_LLM=true. Otherwise SpecSync posts a single "LLM unavailable" comment. OPENAI_API_KEY= ANTHROPIC_API_KEY= LLM_REQUEST_TIMEOUT_MS=120000 LLM_MAX_RETRIES=2 +# When true: allow mock specs even in production (they are badged [MOCK] in comments). +# When false (recommended): production refuses mock output and reports LLM unavailable. +SPECSYNC_ALLOW_MOCK_LLM=false + +# Cap how many function specs are generated per PR (budget / spam control). +SPECSYNC_MAX_SPECS_PER_PR=10 + +# Max parallel LLM calls within one PR analysis job. +SPECSYNC_LLM_CONCURRENCY=3 + +# In-memory spec/content cache TTL (ms). Single-instance only — not multi-tenant durable. +SPECSYNC_CACHE_TTL_MS=3600000 + +# Optional shared-volume job markers for multi-instance idempotency (local FS only; +# NOT written into the target GitHub repo). Leave unset for single-instance memory queue. +# Example: /var/lib/specsync/jobs +SPECSYNC_JOB_MARKERS_DIR= + +# Max mutating /specsync accept|ignore|edit commands per actor per PR per window. +SPECSYNC_COMMAND_RATE_LIMIT=10 +SPECSYNC_COMMAND_RATE_WINDOW_MS=60000 + +# Proof gate: soft (report sorry count, do not fail) | strict (fail on any sorry in specs/). +# Generator closes smoke/Nat fragments with real proofs; undecided LLM posts stay labeled unproved. +SPECSYNC_PROOF_GATE=soft + +# Coverage check threshold (%). Formula: accepted .specsync specs / eligible changed functions. +COVERAGE_THRESHOLD=70 + # --- Slack (optional) --- SLACK_BOT_TOKEN= SLACK_CHANNEL=#specsync @@ -32,9 +65,22 @@ LOG_LEVEL=info # Must match the Lake library layout (see lakefile.lean) SPECS_DIR=specs -# --- Future use --- +# --- Future SaaS only (not used in this remediation program) --- DATABASE_URL= # --- UI / experiments --- +# Experimental feature flags (comma-separated). Keep suggest-only until accept store ships. SPECSYNC_EXPERIMENTAL=suggest-only + +# Optional local dashboard (off by default). Requires both flag and shared secret. +# SPECSYNC_DASHBOARD=1 starts the HTTP server; without a secret it refuses to bind. +# Bind defaults to loopback (DASHBOARD_HOST=127.0.0.1). Do not expose publicly without +# a reverse proxy that terminates TLS and restricts access; the shared secret alone is +# not a full multi-tenant AuthN story. +SPECSYNC_DASHBOARD=0 +SPECSYNC_DASHBOARD_SECRET= DASHBOARD_PORT=3001 +# Bind address for the dashboard (default loopback — do not expose publicly without a reverse proxy). +DASHBOARD_HOST=127.0.0.1 +# Clients must send header: X-SpecSync-Dashboard-Secret: +# or Authorization: Bearer diff --git a/jest.config.js b/jest.config.js index 05e1aff..a8d902e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,7 +12,14 @@ module.exports = { }, roots: ["/test"], testMatch: ["**/*.test.ts"], - collectCoverageFrom: ["src/**/*.ts", "!src/vscode-ui.ts", "!src/demo.ts", "!src/ui-demo.ts"], + // Critical-path coverage. Exclude demo/CLI and unused Phase 4 leftovers not imported by index. + collectCoverageFrom: [ + "src/**/*.ts", + "!src/demo.ts", + "!src/ui-demo.ts", + "!src/ci-integration.ts", + "!src/spec-coverage.ts", + ], coveragePathIgnorePatterns: ["/node_modules/", "/dist/"], coverageThreshold: { global: { diff --git a/specs/Specs.lean b/specs/Specs.lean index a95a7c6..ea1bcf9 100644 --- a/specs/Specs.lean +++ b/specs/Specs.lean @@ -1,8 +1,12 @@ import Std +import Specs.ClosedNatContract /-! Root module for the `Specs` library (`lake build`). - Add generated modules alongside this file so `lake build` validates them. + + - Committed modules under `specs/Specs/` must stay Std-only and lake-buildable. + - Accept-path Lean for target repos is written under `.specsync/` (not this tree). + - This library intentionally has **zero** `sorry` so soft/strict gates have a clean baseline. -/ theorem specsync_smoke : True := trivial diff --git a/specs/Specs/ClosedNatContract.lean b/specs/Specs/ClosedNatContract.lean new file mode 100644 index 0000000..123bf8a --- /dev/null +++ b/specs/Specs/ClosedNatContract.lean @@ -0,0 +1,31 @@ +import Std + +/-! + # SpecSync closed-contract example + + Demonstrates the generator strategy for obligations that need no unfinished proofs: + opaque function under contract + `trivial` smoke + `Nat.zero_le` nonnegativity. + + Open LLM postconditions that depend on an unimplemented body still use + labeled SpecSync unproved markers in generated accept-path files + (under `.specsync/`), not in this committed smoke library. +-/ + +/-- SpecSync: function under contract; body not synthesized from source. -/ +opaque closedNatId (n : Nat) : Nat + +/-- Always-closed smoke obligation for Lake/CI. -/ +theorem closedNatId_specsync_smoke : True := trivial + +/-- Closed: Nat results are nonnegative. -/ +theorem closedNatId_result_nonneg (n : Nat) : + closedNatId n >= 0 := + Nat.zero_le (closedNatId n) + +/-- Closed: Nat parameters are nonnegative. -/ +theorem closedNatId_param_n_nonneg (n : Nat) : + n >= 0 := + Nat.zero_le n + +/-- All checkable fragments above; umbrella is trivial. -/ +theorem closedNatId_spec : True := trivial diff --git a/src/analysis-queue.ts b/src/analysis-queue.ts new file mode 100644 index 0000000..f3bfbe8 --- /dev/null +++ b/src/analysis-queue.ts @@ -0,0 +1,281 @@ +/** + * In-process analysis job queue with idempotency. + * Shape is Redis/BullMQ-replaceable without rewriting callers. + * + * Durability: + * - Default: in-memory only (single Probot instance). + * - Optional: set SPECSYNC_JOB_MARKERS_DIR to a shared filesystem path so + * multiple instances on the same volume can skip already-completed keys. + * Markers are local process state — they are NOT written into the target repo. + */ + +import * as fs from "fs"; +import * as path from "path"; +import type { AnalysisJobPayload } from "./types"; + +export type JobStatus = "pending" | "running" | "completed" | "superseded" | "failed"; + +export interface AnalysisJob { + id: string; + /** Idempotency key: pr:{owner}/{repo}/{number}:{headSha} */ + key: string; + payload: AnalysisJobPayload; + status: JobStatus; + enqueuedAt: number; + startedAt?: number; + finishedAt?: number; + error?: string; +} + +export interface EnqueueResult { + enqueued: boolean; + jobId?: string; + key: string; + reason?: "duplicate" | "superseded_prior" | "in_flight" | "marker_completed"; +} + +export type AnalysisJobRunner = (job: AnalysisJob) => Promise; + +export function buildAnalysisJobKey(payload: AnalysisJobPayload): string { + return `pr:${payload.owner}/${payload.repo}/${payload.pr}:${payload.headSha}`; +} + +function prScopeKey(payload: AnalysisJobPayload): string { + return `pr:${payload.owner}/${payload.repo}/${payload.pr}`; +} + +/** Sanitize idempotency key for use as a filename. */ +export function markerFileName(key: string): string { + return key.replace(/[^a-zA-Z0-9._-]+/g, "_") + ".json"; +} + +export class AnalysisJobQueue { + private jobs = new Map(); + private byPr = new Map>(); + private running = 0; + private readonly maxConcurrent: number; + private readonly markersDir: string | null; + + constructor(options?: { maxConcurrent?: number; markersDir?: string | null }) { + this.maxConcurrent = options?.maxConcurrent ?? 2; + const fromEnv = process.env.SPECSYNC_JOB_MARKERS_DIR; + this.markersDir = + options?.markersDir !== undefined + ? options.markersDir + : fromEnv && fromEnv.trim() + ? fromEnv.trim() + : null; + } + + /** + * Enqueue analysis for a PR head SHA. + * Skips if the same key is completed or in-flight (memory and optional marker file). + * On a new SHA for the same PR, supersedes older pending/running jobs. + */ + enqueue(payload: AnalysisJobPayload, runner: AnalysisJobRunner): EnqueueResult { + const key = buildAnalysisJobKey(payload); + const existing = this.jobs.get(key); + + if (existing && (existing.status === "completed" || existing.status === "running")) { + return { + enqueued: false, + jobId: existing.id, + key, + reason: existing.status === "running" ? "in_flight" : "duplicate", + }; + } + + if (existing && existing.status === "pending") { + return { enqueued: false, jobId: existing.id, key, reason: "in_flight" }; + } + + if (this.readMarkerStatus(key) === "completed") { + return { enqueued: false, key, reason: "marker_completed" }; + } + + const superseded = this.supersedeOlderShas(payload); + const job: AnalysisJob = { + id: `${key}:${Date.now()}`, + key, + payload, + status: "pending", + enqueuedAt: Date.now(), + }; + + this.jobs.set(key, job); + const scope = prScopeKey(payload); + if (!this.byPr.has(scope)) { + this.byPr.set(scope, new Set()); + } + this.byPr.get(scope)!.add(key); + + this.writeMarker(job, "pending"); + this.schedule(job, runner); + + return { + enqueued: true, + jobId: job.id, + key, + reason: superseded > 0 ? "superseded_prior" : undefined, + }; + } + + /** Cancel/supersede jobs for the same PR with a different head SHA. */ + supersedeOlderShas(payload: AnalysisJobPayload): number { + const scope = prScopeKey(payload); + const keys = this.byPr.get(scope); + if (!keys) { + return 0; + } + + let count = 0; + const currentKey = buildAnalysisJobKey(payload); + for (const key of keys) { + if (key === currentKey) { + continue; + } + const job = this.jobs.get(key); + if (!job) { + continue; + } + if (job.status === "pending" || job.status === "running") { + job.status = "superseded"; + job.finishedAt = Date.now(); + this.writeMarker(job, "superseded"); + count += 1; + } + } + return count; + } + + getJob(key: string): AnalysisJob | undefined { + return this.jobs.get(key); + } + + /** Test helper — clears idempotency state between suites. */ + clearForTests(): void { + this.jobs.clear(); + this.byPr.clear(); + this.running = 0; + } + + /** Test/observability helper. */ + getStats(): { total: number; running: number; byStatus: Record } { + const byStatus: Record = { + pending: 0, + running: 0, + completed: 0, + superseded: 0, + failed: 0, + }; + for (const job of this.jobs.values()) { + byStatus[job.status] += 1; + } + return { total: this.jobs.size, running: this.running, byStatus }; + } + + private schedule(job: AnalysisJob, runner: AnalysisJobRunner): void { + setImmediate(() => { + void this.runWhenSlot(job, runner); + }); + } + + private async runWhenSlot(job: AnalysisJob, runner: AnalysisJobRunner): Promise { + while (this.running >= this.maxConcurrent) { + await sleep(25); + if (job.status === "superseded") { + return; + } + } + + if (job.status === "superseded") { + return; + } + + this.running += 1; + job.status = "running"; + job.startedAt = Date.now(); + this.writeMarker(job, "running"); + + try { + await runner(job); + // Status may have been flipped to superseded concurrently while running. + if ((job.status as JobStatus) !== "superseded") { + job.status = "completed"; + this.writeMarker(job, "completed"); + } + } catch (error: unknown) { + if ((job.status as JobStatus) !== "superseded") { + job.status = "failed"; + job.error = error instanceof Error ? error.message : String(error); + this.writeMarker(job, "failed"); + } + throw error; + } finally { + job.finishedAt = Date.now(); + this.running = Math.max(0, this.running - 1); + } + } + + private markerPath(key: string): string | null { + if (!this.markersDir) { + return null; + } + return path.join(this.markersDir, markerFileName(key)); + } + + private readMarkerStatus(key: string): JobStatus | null { + const file = this.markerPath(key); + if (!file) { + return null; + } + try { + if (!fs.existsSync(file)) { + return null; + } + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { status?: JobStatus }; + return raw.status ?? null; + } catch { + return null; + } + } + + private writeMarker(job: AnalysisJob, status: JobStatus): void { + const file = this.markerPath(job.key); + if (!file) { + return; + } + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + JSON.stringify( + { + key: job.key, + jobId: job.id, + status, + payload: job.payload, + updatedAt: new Date().toISOString(), + error: job.error, + }, + null, + 2 + ), + "utf8" + ); + } catch (error: unknown) { + // Markers are best-effort; never block analysis on FS errors. + console.warn( + "[SpecSync] failed to write job marker:", + error instanceof Error ? error.message : String(error) + ); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Shared singleton for the Probot process. */ +export const analysisJobQueue = new AnalysisJobQueue(); diff --git a/src/ast-extractor.ts b/src/ast-extractor.ts index 0384c3e..828f11b 100644 --- a/src/ast-extractor.ts +++ b/src/ast-extractor.ts @@ -1,44 +1,99 @@ -// @ts-nocheck const Parser = require("tree-sitter"); const fs = require("fs-extra"); const path = require("path"); -// Import language parsers +// Import language parsers — only these extensions are AST-supported. const JavaScript = require("tree-sitter-javascript"); const TypeScript = require("tree-sitter-typescript"); const Python = require("tree-sitter-python"); const Java = require("tree-sitter-java"); - const Rust = require("tree-sitter-rust"); +/** + * Extensions with a real tree-sitter grammar in this repo. + * Go / C / C++ are NOT supported (no parsers shipped). + */ +export const SUPPORTED_AST_EXTENSIONS = Object.freeze([ + ".js", + ".jsx", + ".ts", + ".tsx", + ".py", + ".java", + ".rs", +]); + +export const SUPPORTED_LANGUAGE_NAMES = Object.freeze({ + ".js": "JavaScript", + ".jsx": "JavaScript", + ".ts": "TypeScript", + ".tsx": "TypeScript", + ".py": "Python", + ".java": "Java", + ".rs": "Rust", +}); + +/** + * @param {string} filePath + * @returns {boolean} + */ +export function isSupportedAstPath(filePath: string) { + return SUPPORTED_AST_EXTENSIONS.includes(path.extname(filePath)); +} + +/** + * @param {string} filePath + * @returns {string} + */ +export function getLanguageNameFromPath(filePath: string) { + const ext = path.extname(filePath) as keyof typeof SUPPORTED_LANGUAGE_NAMES; + return SUPPORTED_LANGUAGE_NAMES[ext] || "Unknown"; +} + export class ASTExtractor { + parsers: any; + contentCache: any; constructor() { + // tree-sitter-typescript exports { typescript, tsx } + const tsLang = TypeScript.typescript || TypeScript; + const tsxLang = TypeScript.tsx || TypeScript.typescript || TypeScript; + this.parsers = { ".js": JavaScript, ".jsx": JavaScript, - ".ts": TypeScript, - ".tsx": TypeScript, + ".ts": tsLang, + ".tsx": tsxLang, ".py": Python, ".java": Java, - ".rs": Rust, }; + /** Cache getContent by path+ref to avoid N+1 fetches per function. */ + this.contentCache = new Map(); } /** - * Extract AST information for changed functions + * Extract AST information for changed functions. + * Skips files whose extension has no tree-sitter grammar (e.g. Go, C, C++). * @param {Array} functionChanges - Array of function changes from DiffParser * @param {Object} context - GitHub context * @returns {Array} AST analysis results */ - async extractAST(functionChanges, context) { + async extractAST(functionChanges: any[], context: any) { const astResults = []; for (const change of functionChanges) { + if (!isSupportedAstPath(change.filePath)) { + console.log( + `[SpecSync] Skipping unsupported language for AST: ${change.filePath} ` + + `(supported: ${SUPPORTED_AST_EXTENSIONS.join(", ")})` + ); + continue; + } + try { const astAnalysis = await this.analyzeFunction(change, context); astResults.push(astAnalysis); - } catch (error) { + } catch (error: unknown) { console.error(`Error analyzing function ${change.functionName}:`, error); // Continue with other functions } @@ -53,7 +108,7 @@ export class ASTExtractor { * @param {Object} context - GitHub context * @returns {Object} AST analysis result */ - async analyzeFunction(change, context) { + async analyzeFunction(change: any, context: any) { const { filePath, functionName, functionBody } = change; // Get the full file content to parse @@ -88,48 +143,64 @@ export class ASTExtractor { complexity: this.calculateComplexity(functionNode), }, }; - } catch (error) { + } catch (error: unknown) { console.error(`Error parsing AST for ${functionName}:`, error); return this.createBasicAnalysis(change); } } /** - * Get file content from GitHub + * Get file content from GitHub (cached by path+ref for the job lifetime). * @param {string} filePath - Path to file * @param {Object} context - GitHub context * @returns {string} File content */ - async getFileContent(filePath, context) { + async getFileContent(filePath: string, context: any) { try { const { payload } = context; const { repository } = payload; + const ref = + payload.pull_request?.head?.sha || + payload.after || + payload.ref?.replace(/^refs\/heads\//, "") || + "main"; + const cacheKey = `${repository.owner.login}/${repository.name}:${filePath}@${ref}`; + + if (this.contentCache.has(cacheKey)) { + return this.contentCache.get(cacheKey); + } const response = await context.octokit.repos.getContent({ owner: repository.owner.login, repo: repository.name, path: filePath, - ref: payload.pull_request?.head?.sha || "main", + ref, }); - // Decode content if it's base64 encoded + let content = ""; if (response.data.content) { - return Buffer.from(response.data.content, "base64").toString("utf8"); + content = Buffer.from(response.data.content, "base64").toString("utf8"); } - return ""; - } catch (error) { + this.contentCache.set(cacheKey, content); + return content; + } catch (error: unknown) { console.error(`Error getting file content for ${filePath}:`, error); return ""; } } + /** Clear path+ref content cache (call between jobs if reusing the extractor). */ + clearContentCache() { + this.contentCache.clear(); + } + /** * Get language parser from file path * @param {string} filePath - File path * @returns {Object|null} Language parser */ - getLanguageFromPath(filePath) { + getLanguageFromPath(filePath: string) { const ext = path.extname(filePath); return this.parsers[ext] || null; } @@ -140,7 +211,7 @@ export class ASTExtractor { * @param {string} functionName - Function name to find * @returns {Object|null} Function node */ - findFunctionNode(rootNode, functionName) { + findFunctionNode(rootNode: any, functionName: string) { const functionNodes = this.findNodesByType(rootNode, [ "function_declaration", "method_definition", @@ -165,8 +236,8 @@ export class ASTExtractor { * @param {Array} types - Array of node types to find * @returns {Array} Array of matching nodes */ - findNodesByType(node, types) { - const results = []; + findNodesByType(node: any, types: any[]): any[] { + const results: any[] = []; if (types.includes(node.type)) { results.push(node); @@ -184,12 +255,12 @@ export class ASTExtractor { * @param {Object} node - Function node * @returns {string} Function name */ - getFunctionName(node) { + getFunctionName(node: any) { // Look for name in different possible locations const nameNode = node.childForFieldName("name") || node.childForFieldName("identifier") || - node.children.find((child) => child.type === "identifier"); + node.children.find((child: any) => child.type === "identifier"); return nameNode ? nameNode.text : ""; } @@ -200,11 +271,11 @@ export class ASTExtractor { * @param {Object} language - Language parser * @returns {Array} Array of input types */ - extractInputTypes(functionNode, language) { - const parameters = []; + extractInputTypes(functionNode: any, language: any) { + const parameters: any[] = []; const paramList = functionNode.childForFieldName("parameters") || - functionNode.children.find((child) => child.type === "formal_parameters"); + functionNode.children.find((child: any) => child.type === "formal_parameters"); if (!paramList) return parameters; @@ -229,10 +300,10 @@ export class ASTExtractor { * @param {Object} paramNode - Parameter node * @returns {string} Parameter name */ - getParameterName(paramNode) { + getParameterName(paramNode: any) { const nameNode = paramNode.childForFieldName("name") || - paramNode.children.find((child) => child.type === "identifier"); + paramNode.children.find((child: any) => child.type === "identifier"); return nameNode ? nameNode.text : ""; } @@ -242,11 +313,11 @@ export class ASTExtractor { * @param {Object} paramNode - Parameter node * @returns {string} Parameter type */ - getParameterType(paramNode) { + getParameterType(paramNode: any) { const typeNode = paramNode.childForFieldName("type") || paramNode.children.find( - (child) => child.type === "type_annotation" || child.type === "type_identifier" + (child: any) => child.type === "type_annotation" || child.type === "type_identifier" ); return typeNode ? typeNode.text : "any"; @@ -257,7 +328,7 @@ export class ASTExtractor { * @param {Object} paramNode - Parameter node * @returns {boolean} Is optional */ - isOptionalParameter(paramNode) { + isOptionalParameter(paramNode: any) { return ( paramNode.text.includes("?") || paramNode.text.includes("default") || @@ -271,11 +342,11 @@ export class ASTExtractor { * @param {Object} language - Language parser * @returns {string} Output type */ - extractOutputType(functionNode, language) { + extractOutputType(functionNode: any, language: any) { const returnTypeNode = functionNode.childForFieldName("return_type") || functionNode.children.find( - (child) => child.type === "type_annotation" || child.type === "return_type" + (child: any) => child.type === "type_annotation" || child.type === "return_type" ); return returnTypeNode ? returnTypeNode.text : "any"; @@ -286,7 +357,7 @@ export class ASTExtractor { * @param {Object} functionNode - Function AST node * @returns {Array} Array of conditional guards */ - extractConditionalGuards(functionNode) { + extractConditionalGuards(functionNode: any) { const guards = []; const ifStatements = this.findNodesByType(functionNode, ["if_statement"]); @@ -309,7 +380,7 @@ export class ASTExtractor { * @param {Object} functionNode - Function AST node * @returns {Array} Array of loops */ - extractLoops(functionNode) { + extractLoops(functionNode: any) { const loops = []; const loopTypes = ["for_statement", "while_statement", "do_statement", "for_in_statement"]; @@ -333,10 +404,10 @@ export class ASTExtractor { * @param {Object} loopNode - Loop AST node * @returns {string} Loop condition */ - getLoopCondition(loopNode) { + getLoopCondition(loopNode: any) { const condition = loopNode.childForFieldName("condition") || - loopNode.children.find((child) => child.type === "condition" || child.type === "test"); + loopNode.children.find((child: any) => child.type === "condition" || child.type === "test"); return condition ? condition.text : ""; } @@ -346,7 +417,7 @@ export class ASTExtractor { * @param {Object} functionNode - Function AST node * @returns {Array} Array of branching statements */ - extractBranching(functionNode) { + extractBranching(functionNode: any) { const branching = []; const branchTypes = ["if_statement", "switch_statement", "conditional_expression"]; @@ -370,11 +441,11 @@ export class ASTExtractor { * @param {Object} branchNode - Branch AST node * @returns {string} Branch condition */ - getBranchCondition(branchNode) { + getBranchCondition(branchNode: any) { const condition = branchNode.childForFieldName("condition") || branchNode.childForFieldName("test") || - branchNode.children.find((child) => child.type === "condition" || child.type === "test"); + branchNode.children.find((child: any) => child.type === "condition" || child.type === "test"); return condition ? condition.text : ""; } @@ -384,7 +455,7 @@ export class ASTExtractor { * @param {Object} functionNode - Function AST node * @returns {Array} Array of early returns */ - extractEarlyReturns(functionNode) { + extractEarlyReturns(functionNode: any) { const earlyReturns = []; const returnStatements = this.findNodesByType(functionNode, ["return_statement"]); @@ -410,11 +481,11 @@ export class ASTExtractor { * @param {Object} returnNode - Return AST node * @returns {string} Return value */ - getReturnValue(returnNode) { + getReturnValue(returnNode: any) { const value = returnNode.childForFieldName("value") || returnNode.children.find( - (child) => child.type === "expression" || child.type === "identifier" + (child: any) => child.type === "expression" || child.type === "identifier" ); return value ? value.text : ""; @@ -425,7 +496,7 @@ export class ASTExtractor { * @param {Object} functionNode - Function AST node * @returns {Object} Control flow analysis */ - extractControlFlow(functionNode) { + extractControlFlow(functionNode: any) { return { hasEarlyReturns: this.extractEarlyReturns(functionNode).length > 0, hasLoops: this.extractLoops(functionNode).length > 0, @@ -440,7 +511,7 @@ export class ASTExtractor { * @param {Object} functionNode - Function AST node * @returns {number} Complexity score */ - calculateComplexity(functionNode) { + calculateComplexity(functionNode: any) { let complexity = 1; // Base complexity // Add complexity for each conditional @@ -460,7 +531,7 @@ export class ASTExtractor { * @param {Object} node - AST node * @returns {number} Maximum depth */ - calculateMaxDepth(node, currentDepth = 0) { + calculateMaxDepth(node: any, currentDepth: any= 0) { let maxDepth = currentDepth; for (const child of node.children) { @@ -476,7 +547,7 @@ export class ASTExtractor { * @param {Object} change - Function change object * @returns {Object} Basic analysis */ - createBasicAnalysis(change) { + createBasicAnalysis(change: any) { return { ...change, ast: { diff --git a/src/ci-integration.ts b/src/ci-integration.ts index 1b77739..1fb62c7 100644 --- a/src/ci-integration.ts +++ b/src/ci-integration.ts @@ -1,9 +1,11 @@ -// @ts-nocheck -const { exec } = require("child_process"); +const { execFile } = require("child_process"); const fs = require("fs-extra"); const path = require("path"); export class CIIntegration { + leanPath: any; + specsDir: any; + coverageThreshold: any; constructor() { this.leanPath = process.env.LEAN_PATH || "lean"; this.specsDir = process.env.SPECS_DIR || "./specs"; @@ -15,12 +17,12 @@ export class CIIntegration { * @param {Array} specFiles - Array of spec file paths * @returns {Object} Compilation results */ - async runLeanCompilation(specFiles) { + async runLeanCompilation(specFiles: any[]) { const results = { success: true, - compiled: [], - failed: [], - errors: [], + compiled: [] as string[], + failed: [] as string[], + errors: [] as string[], coverage: 0, }; @@ -31,11 +33,11 @@ export class CIIntegration { results.compiled.push(specFile); } else { results.failed.push(specFile); - results.errors.push(result.error); + results.errors.push(result.error ?? "Lean compilation failed"); } - } catch (error) { + } catch (error: unknown) { results.failed.push(specFile); - results.errors.push(error.message); + results.errors.push((error instanceof Error ? error.message : String(error))); } } @@ -51,15 +53,13 @@ export class CIIntegration { * @param {string} specFile - Path to spec file * @returns {Object} Compilation result */ - async compileLeanFile(specFile) { + async compileLeanFile(specFile: string): Promise<{ success: boolean; error?: string; stdout?: string; stderr?: string }> { return new Promise((resolve) => { - const command = `${this.leanPath} --json ${specFile}`; - - exec(command, { timeout: 30000 }, (error, stdout, stderr) => { + execFile(this.leanPath, ["--json", specFile], { timeout: 30000 }, (error: Error | null, stdout: string, stderr: string) => { if (error) { resolve({ success: false, - error: error.message, + error: (error instanceof Error ? error.message : String(error)), stderr: stderr, }); } else { @@ -78,30 +78,30 @@ export class CIIntegration { * @param {Array} failed - Failed compilation files * @returns {number} Coverage percentage */ - calculateCoverage(compiled, failed) { + calculateCoverage(compiled: any[], failed: any[]) { const total = compiled.length + failed.length; if (total === 0) return 0; return Math.round((compiled.length / total) * 100); } /** - * Generate Sigstore signature for successful proofs + * Generate content digests for compiled proof artifacts (not Sigstore). * @param {Array} compiledFiles - Successfully compiled files - * @returns {Object} Signature results + * @returns {Object} Digest results */ - async generateProofSignature(compiledFiles) { + async generateProofSignature(compiledFiles: any[]) { const signatures = []; for (const file of compiledFiles) { try { - const signature = await this.signFile(file); + const digest = await this.contentDigest(file); signatures.push({ file, - signature: signature, + contentDigest: digest, timestamp: new Date().toISOString(), }); - } catch (error) { - console.error(`Failed to sign ${file}:`, error); + } catch (error: unknown) { + console.error(`Failed to digest ${file}:`, error); } } @@ -112,20 +112,25 @@ export class CIIntegration { } /** - * Sign a file using Sigstore (placeholder implementation) - * @param {string} filePath - File to sign - * @returns {string} Signature + * Content digest (SHA-256). Not Sigstore/cosign — do not claim cryptographic signing. + * @param {string} filePath - File to digest + * @returns {string} contentDigest: */ - async signFile(filePath) { - // In a real implementation, this would use Sigstore - // For now, we'll create a simple hash-based signature + async contentDigest(filePath: string) { const fs = require("fs"); const crypto = require("crypto"); const content = fs.readFileSync(filePath, "utf8"); const hash = crypto.createHash("sha256").update(content).digest("hex"); - return `sigstore:${hash}`; + return `contentDigest:${hash}`; + } + + /** + * @deprecated Use contentDigest. Does not perform Sigstore signing. + */ + async signFile(filePath: any) { + return this.contentDigest(filePath); } /** @@ -134,12 +139,12 @@ export class CIIntegration { * @param {Array} criticalFunctions - List of critical functions * @returns {Object} Gating results */ - checkProofGating(compilationResults, criticalFunctions = []) { + checkProofGating(compilationResults: any, criticalFunctions: any[]= []) { const results = { canMerge: true, - reasons: [], - criticalFailures: [], - coverageFailures: [], + reasons: [] as string[], + criticalFailures: [] as string[], + coverageFailures: [] as string[], }; // Check coverage threshold @@ -152,7 +157,7 @@ export class CIIntegration { // Check critical function proofs for (const criticalFunc of criticalFunctions) { - const hasProof = compilationResults.compiled.some((file) => file.includes(criticalFunc)); + const hasProof = compilationResults.compiled.some((file: any) => file.includes(criticalFunc)); if (!hasProof) { results.canMerge = false; @@ -176,7 +181,7 @@ export class CIIntegration { * @param {string} outputPath - Output path for workflow file * @returns {string} Workflow content */ - generateGitHubActionsWorkflow(outputPath = ".github/workflows/lean4-ci.yml") { + generateGitHubActionsWorkflow(outputPath: string= ".github/workflows/lean4-ci.yml") { const workflow = `name: Lean4 Proof Validation on: @@ -254,7 +259,7 @@ jobs: const { execSync } = require('child_process'); execSync(\`lean --json \${path.join(specsDir, file)}\`, { stdio: 'pipe' }); successCount++; - } catch (error) { + } catch (error: unknown) { console.log(\`Proof failed: \${file}\`); } } @@ -320,7 +325,7 @@ jobs: * @param {Array} signatures - Proof signatures * @returns {Object} Status report */ - createProofStatusReport(compilationResults, signatures) { + createProofStatusReport(compilationResults: any, signatures: any[]) { return { timestamp: new Date().toISOString(), coverage: compilationResults.coverage, @@ -338,7 +343,7 @@ jobs: * @param {Object} context - GitHub context * @param {Object} report - Proof status report */ - async postProofResults(context, report) { + async postProofResults(context: any, report: any) { const { payload } = context; const { repository } = payload; @@ -354,7 +359,7 @@ ${ report.errors.length > 0 ? ` ### ❌ Failed Proofs -${report.errors.map((error) => `- ${error}`).join("\n")} +${report.errors.map((error: any) => `- ${error}`).join("\n")} ` : "" } diff --git a/src/command-rate-limit.ts b/src/command-rate-limit.ts new file mode 100644 index 0000000..341b053 --- /dev/null +++ b/src/command-rate-limit.ts @@ -0,0 +1,51 @@ +/** + * In-process rate limiter for mutating `/specsync` comment commands. + * Single-instance only; pair with AuthZ. Not a substitute for GitHub secondary rate limits. + */ + +export interface RateLimitDecision { + allowed: boolean; + retryAfterMs?: number; + remaining: number; +} + +export class CommandRateLimiter { + private readonly hits = new Map(); + private readonly max: number; + private readonly windowMs: number; + + constructor(options?: { max?: number; windowMs?: number }) { + this.max = options?.max ?? Number(process.env.SPECSYNC_COMMAND_RATE_LIMIT || 10); + this.windowMs = + options?.windowMs ?? Number(process.env.SPECSYNC_COMMAND_RATE_WINDOW_MS || 60_000); + } + + /** + * @param actor GitHub login + * @param scope e.g. `acme/app#42` (owner/repo#pr) + */ + allow(actor: string, scope: string): RateLimitDecision { + const key = `${scope}:${actor}`; + const now = Date.now(); + const windowStart = now - this.windowMs; + const times = (this.hits.get(key) || []).filter((t) => t > windowStart); + + if (times.length >= this.max) { + const oldest = times[0] ?? now; + const retryAfterMs = Math.max(0, oldest + this.windowMs - now); + this.hits.set(key, times); + return { allowed: false, retryAfterMs, remaining: 0 }; + } + + times.push(now); + this.hits.set(key, times); + return { allowed: true, remaining: Math.max(0, this.max - times.length) }; + } + + clearForTests(): void { + this.hits.clear(); + } +} + +/** Shared limiter for the Probot process. */ +export const commandRateLimiter = new CommandRateLimiter(); diff --git a/src/dashboard-ui.ts b/src/dashboard-ui.ts deleted file mode 100644 index 5275429..0000000 --- a/src/dashboard-ui.ts +++ /dev/null @@ -1,711 +0,0 @@ -// @ts-nocheck -const express = require("express"); -const path = require("path"); -const { SpecCoverageTracker } = require("./spec-coverage"); -const { CIIntegration } = require("./ci-integration"); - -export class DashboardUI { - constructor() { - this.app = express(); - this.port = process.env.DASHBOARD_PORT || 3001; - this.coverageTracker = new SpecCoverageTracker(); - this.ciIntegration = new CIIntegration(); - - this.setupRoutes(); - this.setupStaticFiles(); - } - - /** - * Setup Express routes - */ - setupRoutes() { - // API Routes - this.app.get("/api/coverage", this.getCoverageData.bind(this)); - this.app.get("/api/functions", this.getFunctionsData.bind(this)); - this.app.get("/api/proofs", this.getProofsData.bind(this)); - this.app.get("/api/drift", this.getDriftData.bind(this)); - this.app.get("/api/graph", this.getGraphData.bind(this)); - this.app.get("/api/recommendations", this.getRecommendations.bind(this)); - - // Dashboard Routes - this.app.get("/", this.renderDashboard.bind(this)); - this.app.get("/functions", this.renderFunctions.bind(this)); - this.app.get("/proofs", this.renderProofs.bind(this)); - this.app.get("/drift", this.renderDrift.bind(this)); - this.app.get("/graph", this.renderGraph.bind(this)); - } - - /** - * Setup static file serving - */ - setupStaticFiles() { - this.app.use(express.static(path.join(__dirname, "../public"))); - this.app.set("view engine", "ejs"); - this.app.set("views", path.join(__dirname, "../views")); - } - - /** - * Start the dashboard server - */ - start() { - this.app.listen(this.port, () => { - console.log(`📊 SpecSync Dashboard running on http://localhost:${this.port}`); - }); - } - - /** - * Get coverage data for API - */ - async getCoverageData(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - const report = this.coverageTracker.generateCoverageReport(analysis); - - res.json({ - success: true, - data: report, - }); - } catch (error) { - res.status(500).json({ - success: false, - error: error.message, - }); - } - } - - /** - * Get functions data for API - */ - async getFunctionsData(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - - const functions = { - total: analysis.totalFunctions, - covered: analysis.coveredFunctions, - uncovered: analysis.uncoveredFunctions, - coverage: analysis.coveragePercentage, - }; - - res.json({ - success: true, - data: functions, - }); - } catch (error) { - res.status(500).json({ - success: false, - error: error.message, - }); - } - } - - /** - * Get proofs data for API - */ - async getProofsData(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - - const proofs = { - total: analysis.specFiles.length, - failed: analysis.failedProofs.length, - stale: analysis.staleProofs.length, - valid: - analysis.specFiles.length - analysis.failedProofs.length - analysis.staleProofs.length, - }; - - res.json({ - success: true, - data: proofs, - }); - } catch (error) { - res.status(500).json({ - success: false, - error: error.message, - }); - } - } - - /** - * Get drift data for API - */ - async getDriftData(req, res) { - try { - // Mock drift data for now - const drift = { - total: 0, - detected: 0, - resolved: 0, - pending: 0, - }; - - res.json({ - success: true, - data: drift, - }); - } catch (error) { - res.status(500).json({ - success: false, - error: error.message, - }); - } - } - - /** - * Get graph data for API - */ - async getGraphData(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - - // Generate graph data - const graph = this.generateGraphData(analysis); - - res.json({ - success: true, - data: graph, - }); - } catch (error) { - res.status(500).json({ - success: false, - error: error.message, - }); - } - } - - /** - * Generate graph data from analysis - */ - generateGraphData(analysis) { - const nodes = []; - const edges = []; - - // Add function nodes - analysis.uncoveredFunctions.forEach((func) => { - nodes.push({ - id: `func_${func.function}`, - label: func.function, - type: "function", - status: "uncovered", - file: func.file, - }); - }); - - // Add spec nodes - analysis.specFiles.forEach((spec) => { - const functionName = this.coverageTracker.extractFunctionNameFromSpec(spec); - if (functionName) { - nodes.push({ - id: `spec_${functionName}`, - label: functionName, - type: "specification", - status: "verified", - file: spec, - }); - - // Add edge from function to spec - edges.push({ - from: `func_${functionName}`, - to: `spec_${functionName}`, - type: "covered_by", - }); - } - }); - - // Add user story nodes - analysis.userStories.forEach((story, index) => { - nodes.push({ - id: `story_${index}`, - label: story.description.substring(0, 30) + "...", - type: "user_story", - status: story.hasFormalGuarantee ? "verified" : "unverified", - }); - }); - - return { nodes, edges }; - } - - /** - * Get recommendations for API - */ - async getRecommendations(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - const report = this.coverageTracker.generateCoverageReport(analysis); - - res.json({ - success: true, - data: report.recommendations, - }); - } catch (error) { - res.status(500).json({ - success: false, - error: error.message, - }); - } - } - - /** - * Render main dashboard - */ - async renderDashboard(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - const report = this.coverageTracker.generateCoverageReport(analysis); - - res.render("dashboard", { - title: "SpecSync Dashboard", - coverage: report.summary, - recommendations: report.recommendations, - timestamp: report.timestamp, - }); - } catch (error) { - res.render("error", { - title: "Error", - error: error.message, - }); - } - } - - /** - * Render functions page - */ - async renderFunctions(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - - res.render("functions", { - title: "Functions Overview", - functions: { - covered: analysis.coveredFunctions, - uncovered: analysis.uncoveredFunctions, - total: analysis.totalFunctions, - coverage: analysis.coveragePercentage, - }, - }); - } catch (error) { - res.render("error", { - title: "Error", - error: error.message, - }); - } - } - - /** - * Render proofs page - */ - async renderProofs(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - - res.render("proofs", { - title: "Proof Status", - proofs: { - total: analysis.specFiles.length, - failed: analysis.failedProofs, - stale: analysis.staleProofs, - valid: - analysis.specFiles.length - analysis.failedProofs.length - analysis.staleProofs.length, - }, - }); - } catch (error) { - res.render("error", { - title: "Error", - error: error.message, - }); - } - } - - /** - * Render drift page - */ - async renderDrift(req, res) { - try { - // Mock drift data - const drift = { - total: 0, - detected: 0, - resolved: 0, - pending: 0, - }; - - res.render("drift", { - title: "Drift Detection", - drift: drift, - }); - } catch (error) { - res.render("error", { - title: "Error", - error: error.message, - }); - } - } - - /** - * Render graph page - */ - async renderGraph(req, res) { - try { - const analysis = await this.coverageTracker.analyzeSpecCoverage({}); - const graph = this.generateGraphData(analysis); - - res.render("graph", { - title: "Spec Graph", - graph: graph, - }); - } catch (error) { - res.render("error", { - title: "Error", - error: error.message, - }); - } - } - - /** - * Generate HTML dashboard - */ - generateHTMLDashboard(data) { - return ` - - - - - - SpecSync Dashboard - - - -
-
-

📊 SpecSync Dashboard

-

Formal specification coverage and verification status

-
- -
-
-
${data.coverage.coveredFunctions}/${data.coverage.totalFunctions}
-
Functions Covered
-
-
-
-
${data.coverage.coveragePercentage}% Coverage
-
- -
-
${data.proofs.valid}
-
Valid Proofs
-
- -
-
${data.proofs.failed}
-
Failed Proofs
-
- -
-
${data.drift.detected}
-
Drift Detected
-
-
- -
-

📋 Recommendations

- ${data.recommendations - .map( - (rec) => ` -
- ${rec.type.toUpperCase()}: ${rec.message} -
Action: ${rec.action} -
- ` - ) - .join("")} -
-
- - - -`; - } - - /** - * Generate React component for dashboard - */ - generateReactComponent() { - return ` -import React, { useState, useEffect } from 'react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; - -const SpecSyncDashboard = () => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchDashboardData(); - const interval = setInterval(fetchDashboardData, 30000); - return () => clearInterval(interval); - }, []); - - const fetchDashboardData = async () => { - try { - const [coverage, functions, proofs, drift] = await Promise.all([ - fetch('/api/coverage').then(r => r.json()), - fetch('/api/functions').then(r => r.json()), - fetch('/api/proofs').then(r => r.json()), - fetch('/api/drift').then(r => r.json()) - ]); - - setData({ - coverage: coverage.data, - functions: functions.data, - proofs: proofs.data, - drift: drift.data - }); - setLoading(false); - } catch (error) { - console.error('Error fetching dashboard data:', error); - setLoading(false); - } - }; - - if (loading) { - return
Loading...
; - } - - return ( -
-
-

📊 SpecSync Dashboard

-

Formal specification coverage and verification status

-
- -
-
-
- {data.functions.covered}/{data.functions.total} -
-
Functions Covered
-
-
-
-
{data.functions.coverage}% Coverage
-
- -
-
{data.proofs.valid}
-
Valid Proofs
-
- -
-
{data.proofs.failed}
-
Failed Proofs
-
- -
-
{data.drift.detected}
-
Drift Detected
-
-
- -
-

📋 Recommendations

- {data.coverage.recommendations.map((rec, index) => ( -
- {rec.type.toUpperCase()}: {rec.message} -
Action: {rec.action} -
- ))} -
-
- ); -}; - -export default SpecSyncDashboard;`; - } - - /** - * Generate Next.js page for dashboard - */ - generateNextJSPage() { - return ` -import { useState, useEffect } from 'react'; -import Head from 'next/head'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; - -export default function Dashboard() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - fetchDashboardData(); - const interval = setInterval(fetchDashboardData, 30000); - return () => clearInterval(interval); - }, []); - - const fetchDashboardData = async () => { - try { - const [coverage, functions, proofs, drift] = await Promise.all([ - fetch('/api/coverage').then(r => r.json()), - fetch('/api/functions').then(r => r.json()), - fetch('/api/proofs').then(r => r.json()), - fetch('/api/drift').then(r => r.json()) - ]); - - setData({ - coverage: coverage.data, - functions: functions.data, - proofs: proofs.data, - drift: drift.data - }); - setLoading(false); - } catch (error) { - console.error('Error fetching dashboard data:', error); - setLoading(false); - } - }; - - return ( - <> - - SpecSync Dashboard - - - -
-
-

📊 SpecSync Dashboard

-

Formal specification coverage and verification status

-
- - {loading ? ( -
Loading...
- ) : ( - <> -
-
-
- {data.functions.covered}/{data.functions.total} -
-
Functions Covered
-
-
-
-
{data.functions.coverage}% Coverage
-
- -
-
{data.proofs.valid}
-
Valid Proofs
-
- -
-
{data.proofs.failed}
-
Failed Proofs
-
- -
-
{data.drift.detected}
-
Drift Detected
-
-
- -
-

📋 Recommendations

- {data.coverage.recommendations.map((rec, index) => ( -
- {rec.type.toUpperCase()}: {rec.message} -
Action: {rec.action} -
- ))} -
- - )} -
- - ); -}`; - } -} diff --git a/src/demo.ts b/src/demo.ts index 6b94864..d2a09ab 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -// @ts-nocheck /** * SpecSync Demo @@ -15,8 +14,9 @@ import { LLMClient } from "./llm-client"; import { Lean4Generator } from "./lean4-generator"; import { CIIntegration } from "./ci-integration"; import { SpecCoverageTracker } from "./spec-coverage"; -import { DashboardUI } from "./dashboard-ui"; -import { DeveloperFeedback } from "./developer-feedback"; +import { WebDashboard } from "./web-dashboard"; +import { SlackAlerts } from "./slack-alerts"; +import { UIPrinciples } from "./ui-principles"; async function runDemo() { console.log("🚀 Starting SpecSync Demo with Track B Implementation\n"); @@ -29,8 +29,13 @@ async function runDemo() { const lean4Generator = new Lean4Generator(); const ciIntegration = new CIIntegration(); const coverageTracker = new SpecCoverageTracker(); - const dashboardUI = new DashboardUI(); - const developerFeedback = new DeveloperFeedback(); + const webDashboard = new WebDashboard(); + const slackAlerts = new SlackAlerts(); + const uiPrinciples = new UIPrinciples(); + + void webDashboard; + void slackAlerts; + void uiPrinciples; // Test LLM connectivity console.log("🔍 Testing LLM Connectivity..."); @@ -55,7 +60,7 @@ index 1234567..abcdefg 100644 + return Math.round(interest * 100) / 100; // Round to 2 decimal places +} + - function add(a, b) { + function add(a: any, b: any) { return a + b; } @@ -15,6 +24,20 @@ function multiply(a, b) { @@ -95,13 +100,13 @@ index 1234567..abcdefg 100644 }, octokit: { repos: { - getContent: async ({ path }) => { + getContent: async ({ path }: { path: string }) => { if (path === "src/calculator.js") { return { data: { content: Buffer.from( ` -function calculateInterest(principal, rate, time) { +function calculateInterest(principal: any, rate: any, time: any) { // Validate inputs if (principal <= 0 || rate < 0 || time <= 0) { throw new Error('Invalid parameters: all values must be positive'); @@ -112,19 +117,19 @@ function calculateInterest(principal, rate, time) { return Math.round(interest * 100) / 100; // Round to 2 decimal places } -function add(a, b) { +function add(a: any, b: any) { return a + b; } -function subtract(a, b) { +function subtract(a: any, b: any) { return a - b; } -function multiply(a, b) { +function multiply(a: any, b: any) { return a * b; } -function validateEmail(email) { +function validateEmail(email: any) { if (!email || typeof email !== 'string') { return false; } @@ -149,14 +154,14 @@ function validateEmail(email) { console.log("📊 Step 1: Parsing Diff..."); const functionChanges = await diffParser.parseDiff(sampleDiff, changedFiles); console.log(`Found ${functionChanges.length} changed functions:`); - functionChanges.forEach((change) => { + functionChanges.forEach((change: any) => { console.log(` - ${change.functionName} (${change.changeType})`); }); console.log("\n🌳 Step 2: AST Analysis..."); const astAnalysis = await astExtractor.extractAST(functionChanges, mockContext); console.log(`AST analysis completed for ${astAnalysis.length} functions:`); - astAnalysis.forEach((analysis) => { + astAnalysis.forEach((analysis: any) => { console.log( ` - ${analysis.functionName}: ${analysis.ast.inputTypes.length} inputs, complexity ${analysis.ast.complexity}` ); @@ -166,7 +171,7 @@ function validateEmail(email) { const specSuggestions = await specAnalyzer.analyzeChanges(astAnalysis, mockContext); console.log(`Generated ${specSuggestions.length} spec suggestions:`); - specSuggestions.forEach((spec) => { + specSuggestions.forEach((spec: any) => { console.log(`\n📋 ${spec.functionName}:`); console.log(` Confidence: ${spec.confidence}%`); console.log(` Preconditions: ${spec.preconditions.length}`); @@ -208,7 +213,7 @@ function validateEmail(email) { } console.log("\n💬 Step 5: GitHub Comment Formatting..."); - specSuggestions.forEach((spec) => { + specSuggestions.forEach((spec: any) => { const commentBody = formatSpecComment(spec); console.log(`\n📝 Comment for ${spec.functionName}:`); console.log(` Length: ${commentBody.length} characters`); @@ -231,33 +236,23 @@ function validateEmail(email) { ); console.log(` Failed Proofs: ${coverageReport.summary.failedProofs}`); - console.log("\n📟 Step 8: Dashboard UI Generation..."); - const dashboardHTML = dashboardUI.generateHTMLDashboard({ - coverage: { coveredFunctions: 15, totalFunctions: 20, coveragePercentage: 75 }, - proofs: { valid: 12, failed: 3, stale: 2 }, - drift: { detected: 1, resolved: 0, pending: 1 }, - recommendations: [ - { - type: "coverage", - priority: "medium", - message: "Add specs for uncovered functions", - action: "Review uncovered functions", - }, - { - type: "proofs", - priority: "high", - message: "3 proofs are failing", - action: "Fix failing proofs", - }, - ], - }); - console.log(` Generated HTML dashboard (${dashboardHTML.length} characters)`); + console.log("\n📟 Step 8: Dashboard (WebDashboard, gated by SPECSYNC_DASHBOARD=1)..."); + console.log( + ` Dashboard module loaded; start() is a no-op unless SPECSYNC_DASHBOARD=1 and secret set` + ); + console.log(` Port default: ${process.env.DASHBOARD_PORT || 3001}`); - console.log("\n🔁 Step 9: Developer Feedback Loop..."); - await developerFeedback.notifySpecSuggestion(specSuggestions[0], mockContext); - const vscodeExtension = await developerFeedback.generateVSCodeExtension(); - console.log(` Generated VSCode extension: ${vscodeExtension}`); - console.log(` Slack notifications: ${developerFeedback.getNotificationStats().queued} queued`); + console.log("\n🔁 Step 9: Confidence transparency + Slack alerts..."); + const sampleSuggestion = specSuggestions[0]; + if (sampleSuggestion) { + const validation = uiPrinciples.validateConfidenceTransparency(sampleSuggestion); + console.log( + ` Confidence validation: ${validation.isValid ? "ok" : "fail"} ` + + `(confidence=${validation.confidenceValue})` + ); + } + slackAlerts.initialize(); + console.log(` Slack alerts initialized (optional SLACK_BOT_TOKEN)`); console.log("\n✅ All Tracks Implementation Complete!"); console.log("\n🎯 Complete SpecSync Implementation:"); @@ -266,8 +261,8 @@ function validateEmail(email) { console.log(" ✅ Track C — Autoformalization Engine (Enhanced Lean4)"); console.log(" ✅ Track D — CI Integration & Proof Validation"); console.log(" ✅ Track E — Spec Coverage & Drift Detection"); - console.log(" ✅ Track F — Dashboard UI (React/Next.js ready)"); - console.log(" ✅ Track G — Developer Feedback Loop (Slack + VSCode)"); + console.log(" ✅ Track F — WebDashboard (.specsync data, secret auth)"); + console.log(" ✅ Track G — Slack alerts + confidence transparency"); console.log("\n🚀 Production Ready Features:"); console.log(" 📊 Real-time spec coverage tracking"); @@ -284,13 +279,13 @@ function validateEmail(email) { console.log(" 3. Install the VSCode extension for developers"); console.log(" 4. Configure Slack workspace for notifications"); console.log(" 5. Set up monitoring and alerting"); - } catch (error) { + } catch (error: unknown) { console.error("❌ Demo failed:", error); } } // Helper function to format spec comments (from index.js) -function formatSpecComment(suggestion) { +function formatSpecComment(suggestion: any) { const { functionName, filePath, @@ -309,16 +304,16 @@ function formatSpecComment(suggestion) { **Confidence:** ${confidence}% ### 📋 Preconditions -${preconditions.map((pre) => `- ${pre}`).join("\n")} +${preconditions.map((pre: any) => `- ${pre}`).join("\n")} ### ✅ Postconditions -${postconditions.map((post) => `- ${post}`).join("\n")} +${postconditions.map((post: any) => `- ${post}`).join("\n")} ### 🔒 Invariants -${invariants.map((inv) => `- ${inv}`).join("\n")} +${invariants.map((inv: any) => `- ${inv}`).join("\n")} ### ⚠️ Edge Cases -${edgeCases.map((edge) => `- ${edge}`).join("\n")} +${edgeCases.map((edge: any) => `- ${edge}`).join("\n")} ### 💭 Reasoning ${reasoning} diff --git a/src/developer-feedback.ts b/src/developer-feedback.ts deleted file mode 100644 index 0844774..0000000 --- a/src/developer-feedback.ts +++ /dev/null @@ -1,738 +0,0 @@ -// @ts-nocheck -const { WebClient } = require("@slack/web-api"); -const fs = require("fs-extra"); -const path = require("path"); - -export class DeveloperFeedback { - constructor() { - this.slack = null; - this.initializeSlack(); - this.notificationQueue = []; - this.vscodeExtensionPath = process.env.VSCODE_EXTENSION_PATH || "./vscode-extension"; - } - - /** - * Initialize Slack client - */ - initializeSlack() { - const slackToken = process.env.SLACK_BOT_TOKEN; - if (slackToken) { - this.slack = new WebClient(slackToken); - console.log("✅ Slack client initialized"); - } else { - console.warn("⚠️ No Slack token found. Notifications will be queued."); - } - } - - /** - * Send Slack notification - * @param {Object} notification - Notification object - */ - async sendSlackNotification(notification) { - if (!this.slack) { - this.notificationQueue.push(notification); - return; - } - - try { - const channel = process.env.SLACK_CHANNEL || "#specsync"; - const message = this.formatSlackMessage(notification); - - await this.slack.chat.postMessage({ - channel: channel, - ...message, - }); - - console.log(`📢 Slack notification sent: ${notification.type}`); - } catch (error) { - console.error("Error sending Slack notification:", error); - this.notificationQueue.push(notification); - } - } - - /** - * Format Slack message - * @param {Object} notification - Notification object - * @returns {Object} Formatted Slack message - */ - formatSlackMessage(notification) { - const baseMessage = { - text: notification.title, - blocks: [ - { - type: "header", - text: { - type: "plain_text", - text: notification.title, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: notification.description, - }, - }, - ], - }; - - // Add action buttons for spec suggestions - if (notification.type === "spec_suggestion") { - baseMessage.blocks.push({ - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "✅ Accept", - }, - style: "primary", - value: `accept_${notification.functionName}`, - }, - { - type: "button", - text: { - type: "plain_text", - text: "✏️ Edit", - }, - value: `edit_${notification.functionName}`, - }, - { - type: "button", - text: { - type: "plain_text", - text: "❌ Ignore", - }, - style: "danger", - value: `ignore_${notification.functionName}`, - }, - ], - }); - } - - // Add context for proof failures - if (notification.type === "proof_failure") { - baseMessage.blocks.push({ - type: "context", - elements: [ - { - type: "mrkdwn", - text: `*File:* ${notification.file}\n*Function:* ${notification.functionName}`, - }, - ], - }); - } - - return baseMessage; - } - - /** - * Notify about new spec suggestion - * @param {Object} spec - Specification object - * @param {Object} context - GitHub context - */ - async notifySpecSuggestion(spec, context) { - const notification = { - type: "spec_suggestion", - title: "🤖 New SpecSync Suggestion", - description: `A new specification has been generated for \`${spec.functionName}\` with ${spec.confidence}% confidence.`, - functionName: spec.functionName, - confidence: spec.confidence, - prUrl: context.payload.pull_request?.html_url, - timestamp: new Date().toISOString(), - }; - - await this.sendSlackNotification(notification); - } - - /** - * Notify about proof failure - * @param {Object} proofFailure - Proof failure object - * @param {Object} context - GitHub context - */ - async notifyProofFailure(proofFailure, context) { - const notification = { - type: "proof_failure", - title: "❌ Proof Validation Failed", - description: `A Lean4 proof has failed validation. Please review and fix the proof.`, - functionName: proofFailure.functionName, - file: proofFailure.file, - error: proofFailure.error, - prUrl: context.payload.pull_request?.html_url, - timestamp: new Date().toISOString(), - }; - - await this.sendSlackNotification(notification); - } - - /** - * Notify about drift detection - * @param {Object} drift - Drift detection object - * @param {Object} context - GitHub context - */ - async notifyDriftDetection(drift, context) { - const notification = { - type: "drift_detection", - title: "⚠️ Spec Drift Detected", - description: `Specification drift detected for \`${drift.functionName}\`. The implementation may no longer satisfy the existing specification.`, - functionName: drift.functionName, - confidence: drift.confidence, - details: drift.driftDetails, - prUrl: context.payload.pull_request?.html_url, - timestamp: new Date().toISOString(), - }; - - await this.sendSlackNotification(notification); - } - - /** - * Generate VSCode extension - * @param {string} outputPath - Output path for extension - * @returns {string} Extension path - */ - async generateVSCodeExtension(outputPath = "./vscode-extension") { - const extensionPath = path.resolve(outputPath); - - // Create extension directory structure - await fs.ensureDir(extensionPath); - await fs.ensureDir(path.join(extensionPath, "src")); - await fs.ensureDir(path.join(extensionPath, "resources")); - - // Generate package.json - const packageJson = this.generateExtensionPackageJson(); - await fs.writeFile( - path.join(extensionPath, "package.json"), - JSON.stringify(packageJson, null, 2) - ); - - // Generate extension.ts - const extensionTs = this.generateExtensionTypeScript(); - await fs.writeFile(path.join(extensionPath, "src/extension.ts"), extensionTs); - - // Generate README - const readme = this.generateExtensionReadme(); - await fs.writeFile(path.join(extensionPath, "README.md"), readme); - - // Generate configuration - const config = this.generateExtensionConfig(); - await fs.writeFile(path.join(extensionPath, "package.json"), JSON.stringify(config, null, 2)); - - console.log(`✅ VSCode extension generated at ${extensionPath}`); - return extensionPath; - } - - /** - * Generate extension package.json - * @returns {Object} Package.json content - */ - generateExtensionPackageJson() { - return { - name: "specsync", - displayName: "SpecSync", - description: "Formal specification integration for VSCode", - version: "1.0.0", - publisher: "specsync", - categories: ["Other"], - activationEvents: [ - "onCommand:specsync.showSpec", - "onCommand:specsync.editSpec", - "onCommand:specsync.runProof", - "onLanguage:javascript", - "onLanguage:typescript", - "onLanguage:python", - "onLanguage:java", - ], - main: "./out/extension.js", - contributes: { - commands: [ - { - command: "specsync.showSpec", - title: "Show Specification", - category: "SpecSync", - }, - { - command: "specsync.editSpec", - title: "Edit Specification", - category: "SpecSync", - }, - { - command: "specsync.runProof", - title: "Run Lean4 Proof", - category: "SpecSync", - }, - ], - configuration: { - title: "SpecSync", - properties: { - "specsync.apiUrl": { - type: "string", - default: "http://localhost:3000", - description: "SpecSync API URL", - }, - "specsync.autoShowSpec": { - type: "boolean", - default: true, - description: "Automatically show specifications when hovering over functions", - }, - }, - }, - }, - scripts: { - "vscode:prepublish": "npm run compile", - compile: "tsc -p ./", - watch: "tsc -watch -p ./", - }, - devDependencies: { - "@types/vscode": "^1.74.0", - "@types/node": "^16.11.7", - typescript: "^4.7.4", - }, - }; - } - - /** - * Generate extension TypeScript - * @returns {string} Extension TypeScript content - */ - generateExtensionTypeScript() { - return ` -import * as vscode from 'vscode'; -import * as path from 'path'; - -export function activate(context: vscode.ExtensionContext) { - console.log('SpecSync extension is now active!'); - - // Register commands - let showSpec = vscode.commands.registerCommand('specsync.showSpec', () => { - showSpecification(); - }); - - let editSpec = vscode.commands.registerCommand('specsync.editSpec', () => { - editSpecification(); - }); - - let runProof = vscode.commands.registerCommand('specsync.runProof', () => { - runLeanProof(); - }); - - context.subscriptions.push(showSpec, editSpec, runProof); - - // Register hover provider - const hoverProvider = vscode.languages.registerHoverProvider( - ['javascript', 'typescript', 'python', 'java'], - { - provideHover(document, position, token) { - return provideSpecHover(document, position); - } - } - ); - - context.subscriptions.push(hoverProvider); -} - -export function deactivate() {} - -async function showSpecification() { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showInformationMessage('No active editor'); - return; - } - - const position = editor.selection.active; - const functionName = getFunctionAtPosition(editor.document, position); - - if (!functionName) { - vscode.window.showInformationMessage('No function found at cursor position'); - return; - } - - try { - const config = vscode.workspace.getConfiguration('specsync'); - const apiUrl = config.get('apiUrl', 'http://localhost:3000'); - - const response = await fetch(\`\${apiUrl}/api/spec/\${functionName}\`); - const spec = await response.json(); - - if (spec.success) { - showSpecPanel(spec.data); - } else { - vscode.window.showInformationMessage('No specification found for this function'); - } - } catch (error) { - vscode.window.showErrorMessage('Failed to fetch specification'); - } -} - -async function editSpecification() { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showInformationMessage('No active editor'); - return; - } - - const position = editor.selection.active; - const functionName = getFunctionAtPosition(editor.document, position); - - if (!functionName) { - vscode.window.showInformationMessage('No function found at cursor position'); - return; - } - - // Open spec editing panel - const panel = vscode.window.createWebviewPanel( - 'specEdit', - 'Edit Specification', - vscode.ViewColumn.One, - { - enableScripts: true - } - ); - - panel.webview.html = generateSpecEditHTML(functionName); -} - -async function runLeanProof() { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showInformationMessage('No active editor'); - return; - } - - const position = editor.selection.active; - const functionName = getFunctionAtPosition(editor.document, position); - - if (!functionName) { - vscode.window.showInformationMessage('No function found at cursor position'); - return; - } - - // Run Lean4 proof - const terminal = vscode.window.createTerminal('SpecSync Proof'); - terminal.show(); - terminal.sendText(\`lean --json specs/\${functionName}_spec.lean\`); -} - -function getFunctionAtPosition(document: vscode.TextDocument, position: vscode.Position): string | null { - const line = document.lineAt(position.line); - const text = line.text; - - // Simple function detection - const functionPatterns = [ - /function\\s+(\\w+)\\s*\\(/, - /const\\s+(\\w+)\\s*=\\s*\\(/, - /def\\s+(\\w+)\\s*\\(/, - /public\\s+\\w+\\s+(\\w+)\\s*\\(/ - ]; - - for (const pattern of functionPatterns) { - const match = text.match(pattern); - if (match) { - return match[1]; - } - } - - return null; -} - -function provideSpecHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | null { - const functionName = getFunctionAtPosition(document, position); - - if (!functionName) { - return null; - } - - const config = vscode.workspace.getConfiguration('specsync'); - const autoShow = config.get('autoShowSpec', true); - - if (!autoShow) { - return null; - } - - // Return hover with spec info - return new vscode.Hover([ - '**SpecSync Specification**', - \`Function: \${functionName}\`, - 'Click to view full specification' - ]); -} - -function showSpecPanel(spec: any) { - const panel = vscode.window.createWebviewPanel( - 'specView', - 'Specification', - vscode.ViewColumn.One, - { - enableScripts: true - } - ); - - panel.webview.html = generateSpecViewHTML(spec); -} - -function generateSpecViewHTML(spec: any): string { - return \` - - - - - - Specification - - - -

Specification for \${spec.functionName}

-
Confidence: \${spec.confidence}%
- -
-
Preconditions:
-
- \${spec.preconditions.map(p => \`
• \${p}
\`).join('')} -
-
- -
-
Postconditions:
-
- \${spec.postconditions.map(p => \`
• \${p}
\`).join('')} -
-
- -
-
Invariants:
-
- \${spec.invariants.map(i => \`
• \${i}
\`).join('')} -
-
- -
-
Edge Cases:
-
- \${spec.edgeCases.map(e => \`
• \${e}
\`).join('')} -
-
- - -\`; -} - -function generateSpecEditHTML(functionName: string): string { - return \` - - - - - - Edit Specification - - - -

Edit Specification for \${functionName}

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - -
- - - - -\`; -} -`; - } - - /** - * Generate extension README - * @returns {string} README content - */ - generateExtensionReadme() { - return `# SpecSync VSCode Extension - -This extension provides integration with SpecSync for formal specification management in VSCode. - -## Features - -- **Inline Specification Display**: Hover over functions to see their specifications -- **Specification Editing**: Edit preconditions, postconditions, invariants, and edge cases -- **Lean4 Proof Execution**: Run Lean4 proofs directly from VSCode -- **Real-time Updates**: Specifications update automatically when changed - -## Commands - -- \`SpecSync: Show Specification\` - Display specification for the function at cursor -- \`SpecSync: Edit Specification\` - Open specification editor -- \`SpecSync: Run Lean4 Proof\` - Execute Lean4 proof for current function - -## Configuration - -- \`specsync.apiUrl\` - SpecSync API URL (default: http://localhost:3000) -- \`specsync.autoShowSpec\` - Automatically show specifications on hover (default: true) - -## Installation - -1. Build the extension: \`npm run compile\` -2. Package the extension: \`vsce package\` -3. Install the .vsix file in VSCode - -## Development - -\`\`\`bash -npm install -npm run compile -npm run watch -\`\`\` - -## Usage - -1. Open a JavaScript/TypeScript/Python/Java file -2. Hover over a function to see its specification -3. Use commands to edit specifications or run proofs -4. Specifications are automatically synced with SpecSync API -`; - } - - /** - * Generate extension configuration - * @returns {Object} Configuration object - */ - generateExtensionConfig() { - return { - name: "specsync", - displayName: "SpecSync", - description: "Formal specification integration for VSCode", - version: "1.0.0", - publisher: "specsync", - categories: ["Other"], - activationEvents: [ - "onCommand:specsync.showSpec", - "onCommand:specsync.editSpec", - "onCommand:specsync.runProof", - ], - main: "./out/extension.js", - contributes: { - commands: [ - { - command: "specsync.showSpec", - title: "Show Specification", - category: "SpecSync", - }, - { - command: "specsync.editSpec", - title: "Edit Specification", - category: "SpecSync", - }, - { - command: "specsync.runProof", - title: "Run Lean4 Proof", - category: "SpecSync", - }, - ], - configuration: { - title: "SpecSync", - properties: { - "specsync.apiUrl": { - type: "string", - default: "http://localhost:3000", - description: "SpecSync API URL", - }, - "specsync.autoShowSpec": { - type: "boolean", - default: true, - description: "Automatically show specifications when hovering over functions", - }, - }, - }, - }, - }; - } - - /** - * Process queued notifications - */ - async processQueuedNotifications() { - if (this.notificationQueue.length === 0) { - return; - } - - console.log(`Processing ${this.notificationQueue.length} queued notifications...`); - - for (const notification of this.notificationQueue) { - try { - await this.sendSlackNotification(notification); - } catch (error) { - console.error("Error processing queued notification:", error); - } - } - - this.notificationQueue = []; - } - - /** - * Get notification statistics - * @returns {Object} Notification statistics - */ - getNotificationStats() { - return { - queued: this.notificationQueue.length, - slackConnected: this.slack !== null, - lastProcessed: new Date().toISOString(), - }; - } -} diff --git a/src/diff-parser.ts b/src/diff-parser.ts index 4144b79..3929752 100644 --- a/src/diff-parser.ts +++ b/src/diff-parser.ts @@ -1,9 +1,9 @@ -// @ts-nocheck const fs = require("fs-extra"); const path = require("path"); const simpleGit = require("simple-git"); export class DiffParser { + git: any; constructor() { this.git = simpleGit(); } @@ -14,8 +14,8 @@ export class DiffParser { * @param {Array} changedFiles - List of changed files from GitHub API * @returns {Array} Array of function changes with metadata */ - async parseDiff(diff, changedFiles) { - const functionChanges = []; + async parseDiff(diff: string, changedFiles: any[]) { + const functionChanges: any[] = []; for (const file of changedFiles) { if (this.isCodeFile(file.filename)) { @@ -28,31 +28,16 @@ export class DiffParser { } /** - * Check if file is a code file that should be analyzed + * Check if file is a code file that should be analyzed. + * Only tree-sitter-backed languages (JS/TS/Python/Java/Rust) are analyzed. * @param {string} filename - File path * @returns {boolean} */ - isCodeFile(filename) { - const codeExtensions = [ - ".js", - ".ts", - ".jsx", - ".tsx", - ".py", - ".java", - ".go", - ".rs", - ".cpp", - ".c", - ".h", - ]; + isCodeFile(filename: string) { + const { isSupportedAstPath } = require("./ast-extractor"); const testPatterns = ["test", "spec", "Test", "Spec"]; - - const ext = path.extname(filename); - const isCodeFile = codeExtensions.includes(ext); - const isTestFile = testPatterns.some((pattern) => filename.includes(pattern)); - - return isCodeFile && !isTestFile; + const isTestFile = testPatterns.some((pattern: any) => filename.includes(pattern)); + return isSupportedAstPath(filename) && !isTestFile; } /** @@ -61,8 +46,8 @@ export class DiffParser { * @param {string} diff - Raw diff content * @returns {Array} Function changes in this file */ - async parseFileDiff(file, diff) { - const functionChanges = []; + async parseFileDiff(file: any, diff: string) { + const functionChanges: any[] = []; const fileDiff = this.extractFileDiff(diff, file.filename); if (!fileDiff) return functionChanges; @@ -71,12 +56,15 @@ export class DiffParser { const functions = this.extractFunctions(fileDiff); for (const func of functions) { + const changeType = this.determineChangeType(func); const change = { filePath: file.filename, functionName: func.name, functionBody: func.body, lineNumber: func.lineNumber, - changeType: this.determineChangeType(func), + // Pure deletions must use LEFT; GitHub rejects RIGHT lines that do not exist on head. + side: func.side || (changeType === "removed" ? "LEFT" : "RIGHT"), + changeType, comments: this.extractComments(fileDiff, func.lineNumber), testFiles: await this.findTestFiles(file.filename), }; @@ -93,7 +81,7 @@ export class DiffParser { * @param {string} filename - Target filename * @returns {string} File-specific diff */ - extractFileDiff(diff, filename) { + extractFileDiff(diff: string, filename: string) { const lines = diff.split("\n"); let inTargetFile = false; let fileDiff = []; @@ -113,71 +101,147 @@ export class DiffParser { } /** - * Extract function definitions from diff + * Parse a unified-diff hunk header into old/new start line numbers and counts. + * @param {string} line - Hunk header line (e.g. @@ -10,6 +12,8 @@ or @@ -1,5 +0,0 @@) + * @returns {{ oldStart: number, oldCount: number, newStart: number, newCount: number } | null} + */ + parseHunkHeader(line: string) { + const match = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/); + if (!match) { + return null; + } + return { + oldStart: parseInt(match[1], 10), + oldCount: match[2] !== undefined ? parseInt(match[2], 10) : 1, + newStart: parseInt(match[3], 10), + newCount: match[4] !== undefined ? parseInt(match[4], 10) : 1, + }; + } + + /** + * Extract function definitions from diff. + * Maps each function start to a GitHub-valid file line + side: + * - RIGHT + new-file line for adds/modifies/context + * - LEFT + old-file line for pure deletions (no head line exists) * @param {string} fileDiff - File-specific diff * @returns {Array} Array of function objects */ - extractFunctions(fileDiff) { - const functions = []; + extractFunctions(fileDiff: string) { + const functions: any[] = []; const lines = fileDiff.split("\n"); - // Common function patterns for different languages const functionPatterns = [ - // JavaScript/TypeScript /^(\+|\-)?\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/, /^(\+|\-)?\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(/, /^(\+|\-)?\s*(?:export\s+)?(\w+)\s*[:=]\s*(?:async\s+)?\(/, - // Python /^(\+|\-)?\s*def\s+(\w+)\s*\(/, - // Java /^(\+|\-)?\s*(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?(?:<[^>]+>\s+)?\w+\s+(\w+)\s*\(/, - // Go /^(\+|\-)?\s*func\s+(\w+)\s*\(/, - // Rust /^(\+|\-)?\s*fn\s+(\w+)\s*\(/, - // C/C++ /^(\+|\-)?\s*(?:static\s+)?(?:inline\s+)?(?:const\s+)?\w+(?:\s*\*)?\s+(\w+)\s*\(/, ]; - let currentFunction = null; + let currentFunction: any = null; let braceCount = 0; let inFunction = false; + let rightLine = 0; + let leftLine = 0; + let inHunk = false; + /** True when the current hunk has zero new-file lines (pure deletion / deleted file). */ + let hunkIsPureDeletion = false; + + const pushCurrent = () => { + if (currentFunction) { + functions.push(currentFunction); + } + currentFunction = null; + inFunction = false; + braceCount = 0; + }; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; + for (const line of lines) { + const hunk = this.parseHunkHeader(line); + if (hunk) { + rightLine = hunk.newStart; + leftLine = hunk.oldStart; + inHunk = true; + hunkIsPureDeletion = hunk.newCount === 0; + continue; + } - // Check for function start + if ( + line.startsWith("diff --git") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("new file") || + line.startsWith("deleted file") || + line.startsWith("similarity index") || + line.startsWith("rename ") || + line.startsWith("Binary files") || + line.startsWith("\\") + ) { + continue; + } + + if (!inHunk) { + continue; + } + + const prefix = line[0]; + const isAdded = prefix === "+"; + const isRemoved = prefix === "-"; + const isContext = !isAdded && !isRemoved; + + const currentRightLine = isRemoved ? null : rightLine > 0 ? rightLine : null; + const currentLeftLine = isAdded ? null : leftLine > 0 ? leftLine : null; + + let startedFunction = false; for (const pattern of functionPatterns) { const match = line.match(pattern); if (match) { if (currentFunction) { - functions.push(currentFunction); + pushCurrent(); } + const useLeft = + isRemoved || hunkIsPureDeletion || (currentRightLine == null && currentLeftLine != null); + currentFunction = { name: match[2], body: line, - lineNumber: i + 1, + lineNumber: useLeft + ? (currentLeftLine ?? currentRightLine ?? 0) + : (currentRightLine ?? currentLeftLine ?? 0), + side: useLeft ? "LEFT" : "RIGHT", changeType: match[1] || "modified", }; - inFunction = true; braceCount = this.countBraces(line); + startedFunction = true; break; } } - // Continue building function body - if (inFunction && currentFunction) { + if (inFunction && currentFunction && !startedFunction) { currentFunction.body += "\n" + line; braceCount += this.countBraces(line); - // Check if function ends if (braceCount === 0 && line.trim() !== "") { - inFunction = false; - functions.push(currentFunction); - currentFunction = null; + pushCurrent(); } + } else if (startedFunction && braceCount === 0 && line.includes("{")) { + // Single-line braced function already complete + pushCurrent(); + } + + if (isRemoved) { + leftLine += 1; + } else if (isAdded) { + rightLine += 1; + } else if (isContext) { + leftLine += 1; + rightLine += 1; } } @@ -193,7 +257,7 @@ export class DiffParser { * @param {string} line - Code line * @returns {number} Net brace count */ - countBraces(line) { + countBraces(line: string) { const openBraces = (line.match(/\{/g) || []).length; const closeBraces = (line.match(/\}/g) || []).length; return openBraces - closeBraces; @@ -204,10 +268,10 @@ export class DiffParser { * @param {Object} func - Function object * @returns {string} Change type */ - determineChangeType(func) { + determineChangeType(func: any) { const lines = func.body.split("\n"); - const hasAdded = lines.some((line) => line.startsWith("+")); - const hasRemoved = lines.some((line) => line.startsWith("-")); + const hasAdded = lines.some((line: any) => line.startsWith("+")); + const hasRemoved = lines.some((line: any) => line.startsWith("-")); if (hasAdded && hasRemoved) { return "modified"; @@ -226,7 +290,7 @@ export class DiffParser { * @param {number} functionLine - Line number of function * @returns {Array} Array of comments */ - extractComments(fileDiff, functionLine) { + extractComments(fileDiff: string, functionLine: number) { const comments = []; const lines = fileDiff.split("\n"); @@ -261,7 +325,7 @@ export class DiffParser { * @param {string} comment - Comment text * @returns {string} Comment type */ - getCommentType(comment) { + getCommentType(comment: string) { if (comment.includes("TODO") || comment.includes("FIXME")) { return "todo"; } else if (comment.includes("@param") || comment.includes("@return")) { @@ -278,7 +342,7 @@ export class DiffParser { * @param {string} filePath - Path to the source file * @returns {Array} Array of test file paths */ - async findTestFiles(filePath) { + async findTestFiles(filePath: string) { const testFiles = []; const dir = path.dirname(filePath); const baseName = path.basename(filePath, path.extname(filePath)); @@ -302,7 +366,7 @@ export class DiffParser { try { await fs.access(testPath); testFiles.push(testPath); - } catch (error) { + } catch (error: unknown) { // File doesn't exist, continue } } diff --git a/src/github-ui.ts b/src/github-ui.ts index 75c0344..1bb15e7 100644 --- a/src/github-ui.ts +++ b/src/github-ui.ts @@ -1,40 +1,131 @@ -// @ts-nocheck const { Octokit } = require("@octokit/rest"); +const { normalizeConfidence } = require("./types"); +const { Lean4Generator } = require("./lean4-generator"); +const { + specStore, + suggestionToStoredSpec, + buildSpecStorePath, + buildLeanStorePath, +} = require("./spec-store"); +const { incr } = require("./metrics"); +const { commandRateLimiter } = require("./command-rate-limit"); + +/** Escape HTML special chars in comment bodies (GitHub still renders limited HTML). */ +function escapeHtml(value: any) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +const MUTATING_COMMANDS = ["/specsync accept", "/specsync ignore", "/specsync edit"]; + +function resolveCommentSide(suggestion: { side?: string; changeType?: string }): "LEFT" | "RIGHT" { + if (suggestion.side === "LEFT" || suggestion.side === "RIGHT") { + return suggestion.side; + } + if (suggestion.changeType === "removed" || suggestion.changeType === "-") { + return "LEFT"; + } + return "RIGHT"; +} export class GitHubUI { + octokit: any; + webhookSecret: any; + leanGenerator: any; constructor() { this.octokit = null; this.webhookSecret = process.env.WEBHOOK_SECRET; + this.leanGenerator = new Lean4Generator(); } /** - * Initialize GitHub client + * Initialize GitHub client for local CLI/demo only. + * Production handlers must use context.octokit (installation auth). * @param {string} token - GitHub token */ - initialize(token) { + initialize(token: string) { this.octokit = new Octokit({ auth: token }); } + /** + * Resolve Octokit: prefer per-request context.octokit, fall back to CLI initialize(). + * @param {Object} context - Probot/GitHub context + * @returns {Object} Octokit instance + */ + getOctokit(context: any) { + if (context?.octokit) { + return context.octokit; + } + if (this.octokit) { + return this.octokit; + } + throw new Error("Octokit not available: pass context.octokit or call initialize() for CLI/demo"); + } + + /** + * Prefer one review with multiple comments over N sequential createReviewComment calls. + * Falls back to per-comment createReviewComment if createReview fails. + */ + async createSpecComments(context: any, specSuggestions: any) { + if (!specSuggestions || specSuggestions.length === 0) { + return []; + } + + const { payload } = context; + const { pull_request, repository } = payload; + const octokit = this.getOctokit(context); + + const comments = specSuggestions.map((suggestion: any) => ({ + path: suggestion.filePath, + line: suggestion.lineNumber, + side: resolveCommentSide(suggestion), + body: this.formatSpecComment(suggestion), + })); + + try { + const review = await octokit.pulls.createReview({ + owner: repository.owner.login, + repo: repository.name, + pull_number: pull_request.number, + commit_id: pull_request.head.sha, + event: "COMMENT", + comments, + }); + return [review]; + } catch (error: unknown) { + console.warn("createReview failed; falling back to individual comments:", (error instanceof Error ? error.message : String(error))); + const results = []; + for (const suggestion of specSuggestions) { + results.push(await this.createSpecComment(context, suggestion)); + } + return results; + } + } + /** * Enhanced Prompt 1.1 — Suggested Spec Comments (LLM-generated) * Goal: Auto-insert spec suggestions as native review comments on new/updated PRs. * Inputs: PR diff, surrounding code, existing tests. * Explicit Outputs: One GitHub review comment per suggestion, formatted with confidence and rationale */ - async createSpecComment(context, specSuggestion) { + async createSpecComment(context: any, specSuggestion: any) { const { payload } = context; const { pull_request, repository } = payload; + const octokit = this.getOctokit(context); const commentBody = this.formatSpecComment(specSuggestion); - // Create review comment anchored to specific line - const reviewComment = await this.octokit.pulls.createReviewComment({ + const reviewComment = await octokit.pulls.createReviewComment({ owner: repository.owner.login, repo: repository.name, pull_number: pull_request.number, commit_id: pull_request.head.sha, path: specSuggestion.filePath, line: specSuggestion.lineNumber, + side: resolveCommentSide(specSuggestion), body: commentBody, }); @@ -43,30 +134,41 @@ export class GitHubUI { /** * Enhanced format spec comment with confidence transparency and action buttons + * Confidence must be in [0, 1]; displayed as a percentage. * @param {Object} specSuggestion - Spec suggestion object * @returns {string} Formatted comment body */ - formatSpecComment(specSuggestion) { - const { functionName, preconditions, postconditions, invariants, confidence, reasoning } = - specSuggestion; + formatSpecComment(specSuggestion: any) { + const functionName = escapeHtml(specSuggestion.functionName); + const filePath = escapeHtml(specSuggestion.filePath); + const preconditions = (specSuggestion.preconditions || []).map(escapeHtml); + const postconditions = (specSuggestion.postconditions || []).map(escapeHtml); + const invariants = (specSuggestion.invariants || []).map(escapeHtml); + const reasoning = escapeHtml(specSuggestion.reasoning); + const confidence = normalizeConfidence(specSuggestion.confidence, 0); + const mockBadge = specSuggestion.isMock ? " **[MOCK]**" : ""; - // Confidence transparency (Prompt 5.2) const confidenceEmoji = confidence >= 0.8 ? "🟢" : confidence >= 0.6 ? "🟡" : "🔴"; const confidenceText = confidence >= 0.8 ? "High" : confidence >= 0.6 ? "Medium" : "Low"; + const confidencePercent = Math.round(confidence * 100); - return `## 🤖 SpecSync: Specification for \`${functionName}\` + const footer = specSuggestion.isMock + ? "*Generated by SpecSync [MOCK] fallback (not a live LLM)*" + : "*Generated by SpecSync AI*"; -**File:** \`${specSuggestion.filePath}\` (line ${specSuggestion.lineNumber}) -**Confidence:** ${confidenceEmoji} ${confidenceText} (${Math.round(confidence * 100)}%) + return `## 🤖 SpecSync: Specification for \`${functionName}\`${mockBadge} + +**File:** \`${filePath}\` (line ${escapeHtml(specSuggestion.lineNumber)}) +**Confidence:** ${confidenceEmoji} ${confidenceText} (${confidencePercent}%) ### 📋 Preconditions -${preconditions.map((pre) => `- ${pre}`).join("\n")} +${preconditions.map((pre: any) => `- ${pre}`).join("\n")} ### ✅ Postconditions -${postconditions.map((post) => `- ${post}`).join("\n")} +${postconditions.map((post: any) => `- ${post}`).join("\n")} ### 🔒 Invariants -${invariants.map((inv) => `- ${inv}`).join("\n")} +${invariants.map((inv: any) => `- ${inv}`).join("\n")} ### 💭 Reasoning ${reasoning} @@ -75,60 +177,117 @@ ${reasoning} **Actions:** - ✅ \`/specsync accept\` - Accept this specification -- ✏️ \`/specsync edit\` - Edit the specification +- ✏️ \`/specsync edit\` - Edit the specification (reply with \`/specsync edit apply\` block) - ❌ \`/specsync ignore\` - Ignore this suggestion - 🔍 \`/specsync review\` - Request manual review -*Generated by SpecSync AI*`; +${footer}`; + } + + /** + * Post a single PR comment when the LLM is unavailable and mock specs are denied. + * @param {Object} context - GitHub context + */ + async createLlmUnavailableComment(context: any) { + const { payload } = context; + const { pull_request, repository } = payload; + const octokit = this.getOctokit(context); + + return octokit.issues.createComment({ + owner: repository.owner.login, + repo: repository.name, + issue_number: pull_request.number, + body: `⚠️ **SpecSync**: SpecSync could not generate specs (LLM unavailable).`, + }); } /** - * Enhanced Prompt 1.2 — Inline Spec Coverage Tags - * Goal: Show real-time spec status beside every changed function in PR view. - * Inputs: Proof status JSON produced by CI. - * Explicit Outputs: Inline badge with tooltip and linked Lean theorem + * Coverage check from real `.specsync/` numbers (no fabricated constants). */ - async createCoverageCheck(context, proofStatus) { + async createCoverageCheck(context: any, proofStatus: any) { const { payload } = context; const { pull_request, repository } = payload; + const octokit = this.getOctokit(context); + const threshold = Number(process.env.COVERAGE_THRESHOLD || 70); + const coverage = Number(proofStatus.coverage) || 0; + + let conclusion = "neutral"; + if (proofStatus.totalFunctions === 0) { + conclusion = "neutral"; + } else if (coverage >= threshold) { + conclusion = "success"; + } else { + conclusion = "failure"; + } - const checkRun = await this.octokit.checks.create({ + const checkRun = await octokit.checks.create({ owner: repository.owner.login, repo: repository.name, name: "SpecSync Coverage", head_sha: pull_request.head.sha, status: "completed", - conclusion: proofStatus.coverage >= 70 ? "success" : "failure", + conclusion, output: { - title: `Spec Coverage: ${proofStatus.coverage}%`, + title: `Spec Coverage: ${coverage}%`, summary: this.generateCoverageSummary(proofStatus), text: this.generateCoverageDetails(proofStatus), }, - annotations: this.generateCoverageAnnotations(proofStatus), + annotations: this.generateCoverageAnnotations(proofStatus).slice(0, 50), }); return checkRun; } /** - * Enhanced generate coverage annotations with tooltips and links - * @param {Object} proofStatus - Proof status object - * @returns {Array} Annotations array + * Create a check run on an arbitrary commit (push / default branch). */ - generateCoverageAnnotations(proofStatus) { - const annotations = []; + async createCommitCheck( + context: any, + { + headSha, + name, + conclusion, + title, + summary, + text = undefined, + }: { + headSha: string; + name: string; + conclusion: string; + title: string; + summary: string; + text?: string; + } + ) { + const { payload } = context; + const { repository } = payload; + const octokit = this.getOctokit(context); + + return octokit.checks.create({ + owner: repository.owner.login, + repo: repository.name, + name, + head_sha: headSha, + status: "completed", + conclusion, + output: { title, summary, text: text || summary }, + }); + } + + generateCoverageAnnotations(proofStatus: any) { + const annotations: any[] = []; - proofStatus.functions.forEach((func) => { + (proofStatus.functions || []).forEach((func: any) => { const statusEmoji = func.hasProof ? "🟢" : "🔴"; - const statusText = func.hasProof ? "Spec verified" : "Missing spec"; + const statusText = func.hasProof ? "Accepted spec in .specsync/" : "No accepted spec"; annotations.push({ path: func.filePath, - start_line: func.line, - end_line: func.line, + start_line: func.line || 1, + end_line: func.line || 1, annotation_level: func.hasProof ? "notice" : "failure", message: `${statusEmoji} ${statusText}`, - title: func.hasProof ? "Spec Verified" : "Missing Spec", + title: func.hasProof ? "Spec Accepted" : "Missing Spec", raw_details: this.generateTooltipContent(func), }); }); @@ -136,70 +295,49 @@ ${reasoning} return annotations; } - /** - * Generate tooltip content for coverage badges - * @param {Object} func - Function object - * @returns {string} Tooltip content - */ - generateTooltipContent(func) { + generateTooltipContent(func: any) { if (func.hasProof) { - return `**Verified Specification** + return `**Accepted Specification** - Theorem: ${func.theorem || "N/A"} - Last verified: ${func.lastVerified || "Unknown"} - [View Proof](${this.generateProofUrl(func)})`; - } else { - return `**Missing Specification** -- No formal specification found -- [Add Specification](${this.generateAddSpecUrl(func)})`; } + return `**Missing Specification** +- No accepted \`.specsync/\` entry +- [Add Specification](${this.generateAddSpecUrl(func)})`; } - /** - * Generate proof URL for tooltip - * @param {Object} func - Function object - * @returns {string} Proof URL - */ - generateProofUrl(func) { - return `${process.env.DASHBOARD_URL}/audit/${func.name}`; + generateProofUrl(func: any) { + return `${process.env.DASHBOARD_URL || ""}/audit/${func.name}`; } - /** - * Generate add spec URL for tooltip - * @param {Object} func - Function object - * @returns {string} Add spec URL - */ - generateAddSpecUrl(func) { - return `${process.env.DASHBOARD_URL}/specs/add?function=${func.name}&file=${func.filePath}&line=${func.line}`; + generateAddSpecUrl(func: any) { + return `${process.env.DASHBOARD_URL || ""}/specs/add?function=${func.name}&file=${func.filePath}&line=${func.line}`; } /** - * Enhanced Prompt 1.3 — ProofCheck Sidebar Panel - * Goal: One expandable sidebar tab summarizing all proof results for this PR. - * Inputs: Proof results, drift report. - * Explicit Outputs: List of touched functions with status and "Prove Now" button + * PR-only proof/drift summary comment. Do not call from push handlers. */ - async createProofCheckComment(context, proofResults) { + async createProofCheckComment(context: any, proofResults: any) { const { payload } = context; - const { pull_request, repository } = payload; + const pullRequest = payload.pull_request; + if (!pullRequest?.number) { + throw new Error("createProofCheckComment requires a pull_request in context"); + } + const { repository } = payload; + const octokit = this.getOctokit(context); const commentBody = this.formatProofCheckComment(proofResults); - const comment = await this.octokit.issues.createComment({ + return octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, - issue_number: pull_request.number, + issue_number: pullRequest.number, body: commentBody, }); - - return comment; } - /** - * Enhanced format proof check comment with expandable sections and action buttons - * @param {Object} proofResults - Proof results object - * @returns {string} Formatted comment body - */ - formatProofCheckComment(proofResults) { + formatProofCheckComment(proofResults: any) { const { functions, drift, coverage } = proofResults; let comment = `## 🔍 ProofCheck Results @@ -211,7 +349,7 @@ ${reasoning} |----------|--------|-------|-------| `; - functions.forEach((func) => { + functions.forEach((func: any) => { const status = func.proofValid ? "✅" : "❌"; const driftStatus = func.hasDrift ? "⚠️" : "✅"; comment += `| \`${func.name}\` | ${status} | ${func.theorem || "N/A"} | ${driftStatus} |\n`; @@ -224,7 +362,7 @@ ${reasoning} ⚠️ Drift Detection (${drift.length} functions) `; - drift.forEach((d) => { + drift.forEach((d: any) => { comment += `### \`${d.functionName}\` **Reason:** ${d.reason} **Previous:** ${d.previousSpec} @@ -245,41 +383,39 @@ ${reasoning} return comment; } - /** - * Generate coverage summary for check run - * @param {Object} proofStatus - Proof status object - * @returns {string} Summary text - */ - generateCoverageSummary(proofStatus) { - const { coverage, totalFunctions, coveredFunctions, failedProofs } = proofStatus; + generateCoverageSummary(proofStatus: any) { + const { coverage, totalFunctions, coveredFunctions, failedProofs, formula } = proofStatus; + const threshold = Number(process.env.COVERAGE_THRESHOLD || 70); return `## SpecSync Coverage Report - **Coverage:** ${coverage}% -- **Functions:** ${coveredFunctions}/${totalFunctions} covered -- **Failed Proofs:** ${failedProofs} - -${coverage >= 70 ? "✅ Coverage threshold met" : "❌ Coverage below threshold"}`; +- **Functions:** ${coveredFunctions}/${totalFunctions} with accepted \`.specsync/\` specs +- **Failed Proofs:** ${failedProofs ?? 0} +- **Formula:** ${formula || "accepted specs covering changed functions / eligible changed functions × 100"} + +${ + totalFunctions === 0 + ? "ℹ️ No eligible changed functions in this PR" + : coverage >= threshold + ? "✅ Coverage threshold met" + : "❌ Coverage below threshold" + }`; } - /** - * Generate detailed coverage report - * @param {Object} proofStatus - Proof status object - * @returns {string} Detailed report - */ - generateCoverageDetails(proofStatus) { + generateCoverageDetails(proofStatus: any) { const { functions, proofs } = proofStatus; let details = "## Function Coverage\n\n"; - functions.forEach((func) => { + (functions || []).forEach((func: any) => { const status = func.hasProof ? "🟢" : "🔴"; details += `${status} \`${func.name}\` - ${func.filePath}:${func.line}\n`; }); - if (proofs.length > 0) { + if (proofs && proofs.length > 0) { details += "\n## Proof Status\n\n"; - proofs.forEach((proof) => { + proofs.forEach((proof: any) => { const status = proof.valid ? "✅" : "❌"; details += `${status} \`${proof.theorem}\` - ${proof.file}\n`; }); @@ -288,92 +424,284 @@ ${coverage >= 70 ? "✅ Coverage threshold met" : "❌ Coverage below threshold" return details; } - /** - * Generate URL for re-running proofs - * @param {Object} proofResults - Proof results object - * @returns {string} URL - */ - generateProveNowUrl(proofResults) { - // This would trigger a GitHub Action dispatch + generateProveNowUrl(proofResults: any) { return `https://github.com/${proofResults.repository}/actions/workflows/lean4-ci.yml`; } - /** - * Generate dashboard URL - * @param {Object} proofResults - Proof results object - * @returns {string} URL - */ - generateDashboardUrl(proofResults) { - return `${process.env.DASHBOARD_URL}/repo/${proofResults.repository}/pr/${proofResults.prNumber}`; + generateDashboardUrl(proofResults: any) { + return `${process.env.DASHBOARD_URL || ""}/repo/${proofResults.repository}/pr/${proofResults.prNumber}`; } - /** - * Generate export URL - * @param {Object} proofResults - Proof results object - * @returns {string} URL - */ - generateExportUrl(proofResults) { - return `${process.env.DASHBOARD_URL}/export/${proofResults.repository}/${proofResults.prNumber}`; + generateExportUrl(proofResults: any) { + return `${process.env.DASHBOARD_URL || ""}/export/${proofResults.repository}/${proofResults.prNumber}`; } /** - * Handle spec comment actions - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object + * Resolve PR number from issue_comment or review_comment payloads. */ - async handleSpecCommentAction(context, comment) { + resolvePrNumber(context: any, comment: any) { const { payload } = context; - const { repository } = payload; + if (payload.pull_request?.number) { + return payload.pull_request.number; + } + if (payload.issue?.pull_request && payload.issue.number) { + return payload.issue.number; + } + if (comment?.pull_request_url) { + const match = comment.pull_request_url.match(/\/pulls\/(\d+)/); + if (match) { + return Number(match[1]); + } + } + return null; + } + + async resolvePrHead(context: any, prNumber: any) { + const { repository } = context.payload; + const octokit = this.getOctokit(context); + const pr = await octokit.pulls.get({ + owner: repository.owner.login, + repo: repository.name, + pull_number: prNumber, + }); + return { + number: pr.data.number, + headRef: pr.data.head.ref, + headSha: pr.data.head.sha, + }; + } - if (comment.body.includes("/specsync accept")) { + async handleSpecCommentAction(context: any, comment: any) { + const body = comment.body || ""; + const isMutating = MUTATING_COMMANDS.some((cmd: any) => body.includes(cmd)); + + if (isMutating) { + const authz = await this.authorizeSpecMutation(context, comment); + if (!authz.allowed) { + await this.replyOnThread( + context, + comment, + `🚫 **SpecSync**: You are not allowed to run this command. ` + + `Only the PR author, PR committers, or users with write permission may ` + + `\`/specsync accept|ignore|edit\`.` + + (authz.reason ? ` (${escapeHtml(authz.reason)})` : "") + ); + return; + } + + const actor = + context.payload?.sender?.login || comment.user?.login || comment.author?.login || "unknown"; + const { repository } = context.payload; + const prNumber = this.resolvePrNumber(context, comment) || 0; + const scope = `${repository.owner.login}/${repository.name}#${prNumber}`; + const rate = commandRateLimiter.allow(actor, scope); + if (!rate.allowed) { + incr("commands_rate_limited"); + const seconds = Math.ceil((rate.retryAfterMs || 0) / 1000); + await this.replyOnThread( + context, + comment, + `⏳ **SpecSync**: Rate limit exceeded for mutating commands on this PR. ` + + `Try again in ~${seconds}s ` + + `(limit: \`SPECSYNC_COMMAND_RATE_LIMIT\` / \`SPECSYNC_COMMAND_RATE_WINDOW_MS\`).` + ); + return; + } + } + + if (body.includes("/specsync edit apply")) { + await this.handleEditApplyAction(context, comment); + } else if (body.includes("/specsync accept")) { await this.handleAcceptAction(context, comment); - } else if (comment.body.includes("/specsync edit")) { + } else if (body.includes("/specsync edit")) { await this.handleEditAction(context, comment); - } else if (comment.body.includes("/specsync ignore")) { + } else if (body.includes("/specsync ignore")) { await this.handleIgnoreAction(context, comment); - } else if (comment.body.includes("/specsync review")) { + } else if (body.includes("/specsync review")) { await this.handleReviewAction(context, comment); } } /** - * Handle accept action - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object + * AuthZ for mutating `/specsync` commands. + * Allowed: PR author, anyone who committed on the PR, or write/maintain/admin on the repo. */ - async handleAcceptAction(context, comment) { - const { payload } = context; - const { repository } = payload; + async authorizeSpecMutation(context: any, comment: any) { + const actor = + context.payload?.sender?.login || comment.user?.login || comment.author?.login || null; + if (!actor) { + return { allowed: false, reason: "missing actor" }; + } - // Extract spec from comment and store it - const spec = this.extractSpecFromComment(comment.body); + const { repository } = context.payload; + const owner = repository.owner.login; + const repo = repository.name; + const octokit = this.getOctokit(context); + const prNumber = this.resolvePrNumber(context, comment); + if (!prNumber) { + return { allowed: false, reason: "not a pull request" }; + } - // Store the accepted spec - await this.storeAcceptedSpec(spec, context); + try { + const pr = await octokit.pulls.get({ owner, repo, pull_number: prNumber }); + if (pr.data.user?.login && pr.data.user.login === actor) { + return { allowed: true, reason: "pr_author" }; + } + } catch (error: unknown) { + return { allowed: false, reason: `pr lookup failed: ${(error instanceof Error ? error.message : String(error))}` }; + } + + try { + const perm = await octokit.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: actor, + }); + const level = perm.data?.permission; + if (level === "admin" || level === "write" || level === "maintain") { + return { allowed: true, reason: `permission:${level}` }; + } + } catch { + // Collaborator API may 404 for outside collaborators without access — fall through. + } + + try { + let commits; + if (typeof octokit.paginate === "function") { + commits = await octokit.paginate(octokit.pulls.listCommits, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + } else { + const listed = await octokit.pulls.listCommits({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + commits = listed.data || []; + } + const isCommitter = commits.some( + (c: any) => + c.author?.login === actor || + c.committer?.login === actor || + c.commit?.author?.name === actor || + c.commit?.committer?.name === actor + ); + if (isCommitter) { + return { allowed: true, reason: "pr_committer" }; + } + } catch { + // ignore and deny + } + + return { allowed: false, reason: "insufficient permission" }; + } - // Hide the comment by marking as resolved - await this.octokit.pulls.dismissReview({ + async replyOnThread(context: any, comment: any, body: any) { + const { repository } = context.payload; + const octokit = this.getOctokit(context); + const prNumber = this.resolvePrNumber(context, comment); + + // Prefer reply to review comment when in_reply_to / review comment id exists + if (comment.id && (comment.pull_request_review_id || comment.path)) { + try { + await octokit.pulls.createReplyForReviewComment({ + owner: repository.owner.login, + repo: repository.name, + pull_number: prNumber, + comment_id: comment.in_reply_to || comment.id, + body, + }); + return; + } catch { + // fall through to issue comment + } + } + + await octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, - pull_number: payload.pull_request.number, - review_id: comment.pull_request_review_id, - message: "Spec accepted and stored", + issue_number: prNumber, + body, }); } - /** - * Extract spec from comment body - * @param {string} commentBody - Comment body - * @returns {Object} Extracted spec - */ - extractSpecFromComment(commentBody) { - // Parse the comment to extract spec details + async handleAcceptAction(context: any, comment: any) { + const { repository } = context.payload; + const prNumber = this.resolvePrNumber(context, comment); + if (!prNumber) { + throw new Error("Cannot accept spec without a pull request"); + } + + const pr = await this.resolvePrHead(context, prNumber); + const spec = this.extractSpecFromComment(comment.body); + if (!spec.functionName || !spec.filePath) { + await this.replyOnThread( + context, + comment, + "⚠️ **SpecSync**: Could not parse function name/file from the suggestion comment." + ); + return; + } + + const stored = suggestionToStoredSpec(spec, { pr: pr.number, sha: pr.headSha }); + const repoRef = { + owner: repository.owner.login, + repo: repository.name, + ref: pr.headRef, + }; + + const { path: jsonPath } = await specStore.storeSpec( + this.getOctokit(context), + repoRef, + stored + ); + + const leanWithMeta = this.leanGenerator.generateLean4File( + { + ...stored.contract, + confidence: stored.confidence, + reasoning: stored.contract.reasoning, + }, + { + functionName: stored.functionName, + inputTypes: [], + outputType: "any", + } + ); + + const { path: leanPath } = await specStore.storeLeanFile( + this.getOctokit(context), + repoRef, + stored.functionName, + leanWithMeta + ); + + await this.replyOnThread( + context, + comment, + `✅ **SpecSync**: Specification accepted and committed to \`${pr.headRef}\`.\n\n` + + `- Spec: [\`${jsonPath}\`](${jsonPath})\n` + + `- Lean: [\`${leanPath}\`](${leanPath})\n\n` + + `Proof gate is **${process.env.SPECSYNC_PROOF_GATE || "soft"}** ` + + `(closed fragments are proved; undecided goals stay labeled unproved; CI reports them).` + ); + incr("specs_accepted"); + } + + extractSpecFromComment(commentBody: any) { const lines = commentBody.split("\n"); const spec = { functionName: "", - preconditions: [], - postconditions: [], - invariants: [], + filePath: "", + lineNumber: 1, + preconditions: [] as string[], + postconditions: [] as string[], + invariants: [] as string[], + edgeCases: [] as string[], confidence: 0, reasoning: "", }; @@ -383,15 +711,25 @@ ${coverage >= 70 ? "✅ Coverage threshold met" : "❌ Coverage below threshold" for (const line of lines) { if (line.includes("Specification for")) { spec.functionName = line.match(/`([^`]+)`/)?.[1] || ""; + } else if (line.includes("**File:**") || line.startsWith("**File:**")) { + const fileMatch = line.match(/`([^`]+)`/); + const lineMatch = line.match(/line\s+(\d+)/i); + if (fileMatch) { + spec.filePath = fileMatch[1]; + } + if (lineMatch) { + spec.lineNumber = Number(lineMatch[1]); + } } else if (line.includes("Confidence:")) { - spec.confidence = parseInt(line.match(/(\d+)%/)?.[1] || "0"); - } else if (line.includes("### 📋 Preconditions")) { + const percent = parseInt(line.match(/(\d+)%/)?.[1] || "0", 10); + spec.confidence = normalizeConfidence(percent, 0); + } else if (line.includes("### 📋 Preconditions") || line.includes("### Preconditions")) { currentSection = "preconditions"; - } else if (line.includes("### ✅ Postconditions")) { + } else if (line.includes("### ✅ Postconditions") || line.includes("### Postconditions")) { currentSection = "postconditions"; - } else if (line.includes("### 🔒 Invariants")) { + } else if (line.includes("### 🔒 Invariants") || line.includes("### Invariants")) { currentSection = "invariants"; - } else if (line.includes("### 💭 Reasoning")) { + } else if (line.includes("### 💭 Reasoning") || line.includes("### Reasoning")) { currentSection = "reasoning"; } else if (line.startsWith("- ") && currentSection !== "reasoning") { const item = line.substring(2); @@ -402,7 +740,7 @@ ${coverage >= 70 ? "✅ Coverage threshold met" : "❌ Coverage below threshold" } else if (currentSection === "invariants") { spec.invariants.push(item); } - } else if (currentSection === "reasoning" && line.trim() && !line.startsWith("---")) { + } else if (currentSection === "reasoning" && line.trim() && !line.startsWith("---") && !line.startsWith("**Actions")) { spec.reasoning += line + "\n"; } } @@ -410,107 +748,183 @@ ${coverage >= 70 ? "✅ Coverage threshold met" : "❌ Coverage below threshold" return spec; } - /** - * Store accepted spec - * @param {Object} spec - Spec object - * @param {Object} context - GitHub context - */ - async storeAcceptedSpec(spec, context) { - // Store in database or file system - const { SpecAnalyzer } = require("./spec-analyzer"); - const specAnalyzer = new SpecAnalyzer(); + /** @deprecated Prefer SpecStore via handleAcceptAction */ + async storeAcceptedSpec(spec: any, context: any) { + const prNumber = this.resolvePrNumber(context, null); + const pr = await this.resolvePrHead(context, prNumber); + const { repository } = context.payload; + const stored = suggestionToStoredSpec(spec, { pr: pr.number, sha: pr.headSha }); + return specStore.storeSpec( + this.getOctokit(context), + { owner: repository.owner.login, repo: repository.name, ref: pr.headRef }, + stored + ); + } - const functionKey = `${context.payload.repository.full_name}:${spec.functionName}`; - await specAnalyzer.storeSpec(functionKey, spec); + async handleEditAction(context: any, comment: any) { + const spec = this.extractSpecFromComment(comment.body); + await this.replyOnThread( + context, + comment, + this.generateEditInstructions(spec) + ); } /** - * Handle edit action - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object + * Apply an edit from a structured comment command (no HTML form / missing route). */ - async handleEditAction(context, comment) { - // Create an editable version of the spec - const spec = this.extractSpecFromComment(comment.body); + async handleEditApplyAction(context: any, comment: any) { + const { repository } = context.payload; + const prNumber = this.resolvePrNumber(context, comment); + if (!prNumber) { + throw new Error("Cannot edit spec without a pull request"); + } - // Generate edit form - const editForm = this.generateEditForm(spec); + const edited = this.parseEditApplyBody(comment.body); + if (!edited.functionName || !edited.filePath) { + await this.replyOnThread( + context, + comment, + "⚠️ **SpecSync**: `/specsync edit apply` needs `function:` and `file:` fields." + ); + return; + } - await this.octokit.issues.createComment({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - issue_number: context.payload.pull_request.number, - body: editForm, - }); + const pr = await this.resolvePrHead(context, prNumber); + const stored = suggestionToStoredSpec(edited, { pr: pr.number, sha: pr.headSha }); + const { path: jsonPath } = await specStore.storeSpec( + this.getOctokit(context), + { owner: repository.owner.login, repo: repository.name, ref: pr.headRef }, + stored, + `specsync: edit spec for ${stored.functionName}` + ); + + await this.replyOnThread( + context, + comment, + `✏️ **SpecSync**: Updated specification committed to \`${pr.headRef}\` at \`${jsonPath}\`.` + ); + incr("specs_edited"); } - /** - * Generate edit form for spec - * @param {Object} spec - Spec object - * @returns {string} Edit form HTML - */ - generateEditForm(spec) { - return `## ✏️ Edit Specification for \`${spec.functionName}\` - -
- - -

Preconditions

- - -

Postconditions

- - -

Invariants

- - -

Reasoning

- - - -
`; - } + parseEditApplyBody(body: any) { + const lines = body.split("\n"); + const spec = { + functionName: "", + filePath: "", + lineNumber: 1, + preconditions: [] as string[], + postconditions: [] as string[], + invariants: [] as string[], + edgeCases: [] as string[], + confidence: 0.5, + reasoning: "", + }; + let section = ""; + + for (const raw of lines) { + const line = raw.trim(); + if (line.startsWith("function:")) { + spec.functionName = line.slice("function:".length).trim(); + } else if (line.startsWith("file:")) { + spec.filePath = line.slice("file:".length).trim(); + } else if (line.startsWith("preconditions:")) { + section = "preconditions"; + } else if (line.startsWith("postconditions:")) { + section = "postconditions"; + } else if (line.startsWith("invariants:")) { + section = "invariants"; + } else if (line.startsWith("reasoning:")) { + section = "reasoning"; + const rest = line.slice("reasoning:".length).trim(); + if (rest) { + spec.reasoning += rest + "\n"; + } + } else if (line.startsWith("- ") && section && section !== "reasoning") { + (spec as any)[section].push(line.slice(2)); + } else if (section === "reasoning" && line && !line.startsWith("/specsync") && line !== "```") { + spec.reasoning += line + "\n"; + } + } - /** - * Handle ignore action - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object - */ - async handleIgnoreAction(context, comment) { - // Mark comment as resolved without storing spec - const { payload } = context; - const { repository } = payload; + return spec; + } - await this.octokit.pulls.dismissReview({ - owner: repository.owner.login, - repo: repository.name, - pull_number: payload.pull_request.number, - review_id: comment.pull_request_review_id, - message: "Spec suggestion ignored", - }); + generateEditInstructions(spec: any) { + return `## ✏️ Edit Specification for \`${spec.functionName || "unknown"}\` + +Reply with: + +\`\`\` +/specsync edit apply +function: ${spec.functionName || ""} +file: ${spec.filePath || ""} +preconditions: +${(spec.preconditions || []).map((p: any) => `- ${p}`).join("\n") || "- "} +postconditions: +${(spec.postconditions || []).map((p: any) => `- ${p}`).join("\n") || "- "} +invariants: +${(spec.invariants || []).map((p: any) => `- ${p}`).join("\n") || "- "} +reasoning: ${(spec.reasoning || "").trim()} +\`\`\` + +This commits the updated contract under \`.specsync/specs/\` on the PR branch.`; } - /** - * Handle review action - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object - */ - async handleReviewAction(context, comment) { - // Create a review request - const { payload } = context; - const { repository } = payload; + /** @deprecated HTML forms are not supported; use generateEditInstructions */ + generateEditForm(spec: any) { + return this.generateEditInstructions(spec); + } - await this.octokit.issues.createComment({ - owner: repository.owner.login, - repo: repository.name, - issue_number: payload.pull_request.number, - body: `## 🔍 Manual Review Requested + async handleIgnoreAction(context: any, comment: any) { + const { repository } = context.payload; + const prNumber = this.resolvePrNumber(context, comment); + if (!prNumber) { + throw new Error("Cannot ignore spec without a pull request"); + } -A manual review has been requested for the specification suggestion. + const spec = this.extractSpecFromComment(comment.body); + const pr = await this.resolvePrHead(context, prNumber); + + if (spec.functionName && spec.filePath) { + await specStore.addIgnore( + this.getOctokit(context), + { owner: repository.owner.login, repo: repository.name, ref: pr.headRef }, + { + functionName: spec.functionName, + filePath: spec.filePath, + ignoredAt: new Date().toISOString(), + pr: pr.number, + reason: "Ignored via /specsync ignore", + } + ); + } -**Function:** ${this.extractSpecFromComment(comment.body).functionName} + await this.replyOnThread( + context, + comment, + `❌ **SpecSync**: Suggestion ignored` + + (spec.functionName + ? ` and recorded in \`.specsync/ignores.json\` for \`${spec.functionName}\`.` + : ".") + ); + incr("specs_ignored"); + } -Please review the suggested specification and provide feedback.`, - }); + async handleReviewAction(context: any, comment: any) { + const spec = this.extractSpecFromComment(comment.body); + await this.replyOnThread( + context, + comment, + `## 🔍 Manual Review Requested\n\n**Function:** \`${spec.functionName || "unknown"}\`\n\nPlease review the suggested specification and provide feedback.` + ); } } + +// Re-export path helpers for tests +export const githubUiPathHelpers = { + buildSpecStorePath, + buildLeanStorePath, +}; +(GitHubUI as any).buildSpecStorePath = buildSpecStorePath; +(GitHubUI as any).buildLeanStorePath = buildLeanStorePath; diff --git a/src/index.ts b/src/index.ts index 9222b90..2661f83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,14 @@ import { DiffParser } from "./diff-parser"; import { ASTExtractor } from "./ast-extractor"; import { SpecAnalyzer } from "./spec-analyzer"; import { GitHubUI } from "./github-ui"; -import { VSCodeUI } from "./vscode-ui"; import { WebDashboard } from "./web-dashboard"; import { SlackAlerts } from "./slack-alerts"; import { UIPrinciples } from "./ui-principles"; +import { HealthChecker } from "./health"; +import { analysisJobQueue } from "./analysis-queue"; +import { specStore } from "./spec-store"; +import { getMetrics, incr } from "./metrics"; +import type { AnalysisJobPayload, DriftFinding } from "./types"; const probotApp: ApplicationFunction = (app, options: ApplicationFunctionOptions) => { const diffParser = new DiffParser(); @@ -14,42 +18,81 @@ const probotApp: ApplicationFunction = (app, options: ApplicationFunctionOptions const specAnalyzer = new SpecAnalyzer(); const githubUI = new GitHubUI(); - const vscodeUI = new VSCodeUI(); const webDashboard = new WebDashboard(); const slackAlerts = new SlackAlerts(); const uiPrinciples = new UIPrinciples(); + const healthChecker = new HealthChecker(); - void vscodeUI; - void webDashboard; - void uiPrinciples; + // Dashboard listens only when SPECSYNC_DASHBOARD=1 and SPECSYNC_DASHBOARD_SECRET are set. + webDashboard.start(); const router = options.getRouter?.("/"); if (router) { router.get("/health", (_req, res) => { - res.status(200).json({ status: "healthy", service: "specsync" }); + const status = healthChecker.getStatus(); + res.status(200).json({ + status: "healthy", + service: "specsync", + uptime: status.uptime, + timestamp: status.timestamp, + }); }); router.get("/ready", (_req, res) => { - res.status(200).json({ status: "ready", service: "specsync" }); + const status = healthChecker.getStatus(); + const jobs = analysisJobQueue.getStats(); + res.status(200).json({ + status: "ready", + service: "specsync", + uptime: status.uptime, + jobs, + metrics: getMetrics(), + dashboard: + process.env.SPECSYNC_DASHBOARD === "1" && Boolean(process.env.SPECSYNC_DASHBOARD_SECRET) + ? "enabled" + : "disabled", + timestamp: new Date().toISOString(), + }); }); } - githubUI.initialize(process.env.GITHUB_TOKEN); + // PR path uses context.octokit (installation auth). GITHUB_TOKEN is for local CLI/demo only. slackAlerts.initialize(); app.on("pull_request.opened", async (context) => { - await handlePullRequest(context, diffParser, astExtractor, specAnalyzer, githubUI); + await enqueuePullRequestAnalysis( + context, + diffParser, + astExtractor, + specAnalyzer, + githubUI, + uiPrinciples + ); }); app.on("pull_request.synchronize", async (context) => { - await handlePullRequest(context, diffParser, astExtractor, specAnalyzer, githubUI); + await enqueuePullRequestAnalysis( + context, + diffParser, + astExtractor, + specAnalyzer, + githubUI, + uiPrinciples + ); }); app.on("pull_request.reopened", async (context) => { - await handlePullRequest(context, diffParser, astExtractor, specAnalyzer, githubUI); + await enqueuePullRequestAnalysis( + context, + diffParser, + astExtractor, + specAnalyzer, + githubUI, + uiPrinciples + ); }); app.on("push", async (context) => { - await handlePush(context, diffParser, astExtractor, specAnalyzer, githubUI, slackAlerts); + await handlePush(context, specAnalyzer, githubUI, slackAlerts); }); app.on("issue_comment.created", async (context) => { @@ -61,19 +104,96 @@ const probotApp: ApplicationFunction = (app, options: ApplicationFunctionOptions }); }; -async function handlePullRequest( +async function enqueuePullRequestAnalysis( context: Context<"pull_request">, diffParser: DiffParser, astExtractor: ASTExtractor, specAnalyzer: SpecAnalyzer, - githubUI: GitHubUI + githubUI: GitHubUI, + uiPrinciples: UIPrinciples +): Promise { + const { payload } = context; + const { pull_request, repository, installation } = payload; + + const jobPayload: AnalysisJobPayload = { + installationId: installation?.id ?? 0, + owner: repository.owner.login, + repo: repository.name, + pr: pull_request.number, + headSha: pull_request.head.sha, + }; + + const result = analysisJobQueue.enqueue(jobPayload, async (job) => { + if (job.status === "superseded") { + return; + } + await runPullRequestPipeline( + context, + diffParser, + astExtractor, + specAnalyzer, + githubUI, + uiPrinciples, + job.id + ); + }); + + if (result.enqueued) { + context.log.info( + { + jobId: result.jobId, + jobKey: result.key, + reason: result.reason, + pr: pull_request.number, + headSha: pull_request.head.sha, + }, + `Enqueued SpecSync analysis for PR #${pull_request.number}` + ); + try { + await context.octokit.issues.createComment({ + owner: repository.owner.login, + repo: repository.name, + issue_number: pull_request.number, + body: + `⏳ **SpecSync**: Analysis started for \`${pull_request.head.sha.slice(0, 7)}\`` + + (result.reason === "superseded_prior" ? " (superseded prior head)." : "."), + }); + } catch (error: unknown) { + context.log.warn({ err: error }, "Could not post analysis-started comment"); + } + } else { + context.log.info( + { jobKey: result.key, reason: result.reason }, + `Skipped duplicate SpecSync analysis for PR #${pull_request.number}` + ); + } +} + +async function runPullRequestPipeline( + context: Context<"pull_request">, + diffParser: DiffParser, + astExtractor: ASTExtractor, + specAnalyzer: SpecAnalyzer, + githubUI: GitHubUI, + uiPrinciples: UIPrinciples, + jobId?: string ): Promise { const { payload } = context; const { pull_request, repository } = payload; + const startedAt = Date.now(); + const logBase = { + jobId, + pr: pull_request.number, + owner: repository.owner.login, + repo: repository.name, + headSha: pull_request.head.sha, + }; - context.log.info(`Processing PR #${pull_request.number} in ${repository.full_name}`); + context.log.info(logBase, `Processing PR #${pull_request.number} in ${repository.full_name}`); try { + astExtractor.clearContentCache?.(); + const diff = await context.octokit.pulls.get({ owner: repository.owner.login, repo: repository.name, @@ -83,42 +203,107 @@ async function handlePullRequest( }, }); - const changedFiles = await context.octokit.pulls.listFiles({ + const changedFiles = await context.octokit.paginate(context.octokit.pulls.listFiles, { owner: repository.owner.login, repo: repository.name, pull_number: pull_request.number, + per_page: 100, }); const functionChanges = await diffParser.parseDiff( diff.data as unknown as string, - changedFiles.data + changedFiles ); const astAnalysis = await astExtractor.extractAST(functionChanges, context); const specSuggestions = await specAnalyzer.analyzeChanges(astAnalysis, context); + const cost = (specAnalyzer as { lastJobCost?: Record }).lastJobCost; + // Phase 6: production without LLM keys/API must not post mock review comments. + if (specSuggestions.length === 0 && (specAnalyzer as { lastLlmUnavailable?: boolean }).lastLlmUnavailable) { + incr("llm_failures"); + await githubUI.createLlmUnavailableComment(context); + context.log.warn( + { ...logBase, latencyMs: Date.now() - startedAt, llm: cost, mode: "unavailable" }, + "PR analysis skipped: LLM unavailable" + ); + return; + } + + // Phase 9: enforce 0–1 confidence transparency before posting. + const validatedSuggestions = []; for (const suggestion of specSuggestions) { - await githubUI.createSpecComment(context, suggestion); + const validation = uiPrinciples.validateConfidenceTransparency(suggestion); + if (!validation.isValid) { + context.log.warn( + { + ...logBase, + functionName: suggestion.functionName, + issues: validation.issues, + }, + "Spec suggestion failed confidence transparency validation; skipping" + ); + continue; + } + validatedSuggestions.push(suggestion); } + const mockCount = validatedSuggestions.filter((s) => s.isMock).length; + const liveCount = validatedSuggestions.length - mockCount; + + await githubUI.createSpecComments(context, validatedSuggestions); + incr("specs_posted", validatedSuggestions.length); + + const coverage = await specStore.computeCoverageForFunctions( + // Installation octokit satisfies the store's structural OctokitLike at runtime. + context.octokit as never, + { + owner: repository.owner.login, + repo: repository.name, + ref: pull_request.head.sha, + }, + functionChanges.map((f: { functionName: string; filePath: string; lineNumber?: number }) => ({ + functionName: f.functionName, + filePath: f.filePath, + lineNumber: f.lineNumber, + })) + ); + const proofStatus = { - coverage: 75, - totalFunctions: specSuggestions.length, - coveredFunctions: specSuggestions.filter((s) => s.confidence > 70).length, + coverage: coverage.coverage, + totalFunctions: coverage.totalFunctions, + coveredFunctions: coverage.coveredFunctions, failedProofs: 0, - functions: specSuggestions.map((s) => ({ - name: s.functionName, - filePath: s.filePath, - line: s.lineNumber, - hasProof: s.confidence > 70, - })), + formula: coverage.formula, + functions: coverage.functions, proofs: [] as unknown[], }; await githubUI.createCoverageCheck(context, proofStatus); + + incr("analysis_jobs_completed"); + context.log.info( + { + ...logBase, + latencyMs: Date.now() - startedAt, + specsPosted: validatedSuggestions.length, + llm: { + ...cost, + mock: mockCount, + live: liveCount, + }, + coverage: coverage.coverage, + metrics: getMetrics(), + }, + "PR analysis completed" + ); } catch (error: unknown) { - context.log.error({ err: error }, "Error processing pull request"); + incr("analysis_jobs_failed"); + context.log.error( + { ...logBase, err: error, latencyMs: Date.now() - startedAt }, + "Error processing pull request" + ); await context.octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, @@ -130,16 +315,12 @@ async function handlePullRequest( async function handlePush( context: Context<"push">, - diffParser: DiffParser, - astExtractor: ASTExtractor, specAnalyzer: SpecAnalyzer, githubUI: GitHubUI, slackAlerts: SlackAlerts ): Promise { - void diffParser; - void astExtractor; const { payload } = context; - const { ref, repository } = payload; + const { ref, repository, after } = payload; if (!ref.includes("refs/heads/main") && !ref.includes("refs/heads/master")) { return; @@ -148,27 +329,71 @@ async function handlePush( context.log.info(`Processing push to ${ref} in ${repository.full_name}`); try { - const driftResults = await specAnalyzer.detectDrift(payload.commits, context); + const driftResults: DriftFinding[] = await specAnalyzer.detectDrift(payload.commits, context); - if (driftResults.length > 0) { - for (const drift of driftResults) { - await slackAlerts.sendDriftAlert(drift); - } + const headSha = after || payload.head_commit?.id; + if (!headSha) { + context.log.warn("Push payload missing head SHA; skipping check run"); + return; + } - const proofResults = { - functions: driftResults.map((d) => ({ - name: d.functionName, - proofValid: false, - theorem: null, - hasDrift: true, - })), - drift: driftResults, - coverage: 60, - repository: repository.full_name, - prNumber: 0, - }; + if (driftResults.length === 0) { + await githubUI.createCommitCheck(context, { + headSha, + name: "SpecSync Drift", + conclusion: "success", + title: "No spec drift detected", + summary: + "Compared changed files against accepted `.specsync/` specs. No drift findings " + + "(or no stored specs for changed functions).", + }); + return; + } + + incr("drifts_detected", driftResults.length); + context.log.info( + { drifts: driftResults.length, ref, headSha, metrics: getMetrics() }, + "Spec drift findings" + ); + + const summaryLines = driftResults + .map((d) => `- \`${d.filePath}\` / \`${d.functionName}\`: ${d.reason}`) + .join("\n"); - await githubUI.createProofCheckComment(context, proofResults); + await githubUI.createCommitCheck(context, { + headSha, + name: "SpecSync Drift", + conclusion: "neutral", + title: `Drift: ${driftResults.length} finding(s)`, + summary: `## SpecSync Drift Report\n\n${summaryLines}`, + text: summaryLines, + }); + + // Open a tracking issue (not a PR-only API). Labels are best-effort. + try { + await context.octokit.issues.create({ + owner: repository.owner.login, + repo: repository.name, + title: `[SpecSync] Spec drift on ${ref.replace("refs/heads/", "")} (${headSha.slice(0, 7)})`, + body: + `SpecSync detected **${driftResults.length}** drift finding(s) after push to \`${ref}\`.\n\n` + + `${summaryLines}\n\n` + + `Accepted specs live under \`.specsync/specs/\`. Update or re-accept specs as needed.`, + }); + } catch (error: unknown) { + context.log.warn({ err: error }, "Could not open drift tracking issue"); + } + for (const drift of driftResults) { + await slackAlerts.sendDriftAlert({ + functionName: drift.functionName, + module: drift.filePath, + reason: drift.reason, + severity: "medium", + previousSpec: drift.previousSpec, + currentImplementation: drift.currentImplementation, + repository: repository.full_name, + commit: headSha, + }); } } catch (error: unknown) { context.log.error({ err: error }, "Error processing push"); @@ -180,6 +405,9 @@ async function handleIssueComment( githubUI: GitHubUI ): Promise { const { payload } = context; + if (!payload.issue.pull_request) { + return; + } if (payload.comment.body.includes("/specsync")) { await githubUI.handleSpecCommentAction(context, payload.comment); } diff --git a/src/lean4-generator.ts b/src/lean4-generator.ts index 8f87607..34e528b 100644 --- a/src/lean4-generator.ts +++ b/src/lean4-generator.ts @@ -1,23 +1,53 @@ -// @ts-nocheck const path = require("path"); +const { normalizeConfidence } = require("./types"); /** Output root for generated `.lean` files; must match Lake `specs/` (see `lakefile.lean`). */ const SPECS_DIR_DEFAULT = process.env.SPECS_DIR || "specs"; +/** Match a standalone `sorry` token (not part of an identifier). */ +const SORRY_TOKEN_RE = /(^|[^A-Za-z0-9_])sorry(?=[^A-Za-z0-9_]|$)/g; + +type InputType = { name: string; type: string; required?: boolean }; + +type FormalPred = + | { kind: "true" } + | { kind: "nat_ge_zero"; name: string } + | { kind: "nat_gt_zero"; name: string } + | { kind: "nat_cmp"; name: string; op: ">" | ">=" | "<" | "<="; rhs: string } + | { kind: "bool_eq"; name: string; value: boolean } + | { kind: "str_nonempty"; name: string } + | { kind: "str_empty"; name: string } + | { kind: "ne_none"; name: string } + | { kind: "eq_none"; name: string } + | { kind: "raw"; text: string; reason: string }; + +/** + * Lean 4 generator aligned with Std-only Lake (`lakefile.lean`). + * + * Strategy (honest, no fake proofs): + * - Function under contract → `opaque` (builds without `sorry`). + * - Smoke + decidable Nat/Bool/String fragments → real proofs (`trivial`, `Nat.zero_le`, …). + * - LLM NL that cannot be checked from the opaque stub → labeled + * `/- SpecSync: unproved: -/` + `sorry` only. + * - Security/performance/NL helpers → documentation comments, not invalid Lean. + */ export class Lean4Generator { + typeMap: Record; + complexTypeMap: Record; + auxiliaryDefinitions: Record; + constructor() { this.typeMap = { - number: "ℕ", - int: "ℤ", - float: "ℝ", + number: "Nat", + int: "Int", + float: "String", string: "String", boolean: "Bool", array: "List", - object: "Object", - any: "α", + object: "String", + any: "String", }; - // Enhanced type mapping for complex types this.complexTypeMap = { Account: "Account", Money: "Money", @@ -27,11 +57,10 @@ export class Lean4Generator { Transaction: "Transaction", }; - // Common auxiliary definitions this.auxiliaryDefinitions = { Account: ` structure Account where - id : ℕ + id : Nat balance : Money owner : String isActive : Bool @@ -39,7 +68,7 @@ structure Account where Money: ` structure Money where - amount : ℝ + amount : Nat currency : String deriving Repr`, @@ -51,7 +80,7 @@ structure Email where User: ` structure User where - id : ℕ + id : Nat name : String email : Email isVerified : Bool @@ -61,47 +90,33 @@ structure User where /** * Generate Lean4 theorem skeleton from specification - * @param {Object} spec - Specification object - * @param {Object} context - Function context - * @returns {Object} Lean4 theorem and supporting definitions */ - generateLean4Theorem(spec, context) { + generateLean4Theorem(spec: any, context: any) { const { functionName, inputTypes, outputType } = context; const { preconditions, postconditions, invariants, edgeCases, complexity, security } = spec; - // Generate type definitions - const typeDefs = this.generateTypeDefinitions(inputTypes, outputType); - - // Generate auxiliary definitions - const auxDefs = this.generateAuxiliaryDefinitions(inputTypes, outputType); - - // Generate theorem statement + const typeDefs = this.generateTypeDefinitions(functionName, inputTypes || [], outputType); + const auxDefs = this.generateAuxiliaryDefinitions(inputTypes || [], outputType); const theorem = this.generateTheoremStatement( functionName, - inputTypes, + inputTypes || [], outputType, - preconditions, - postconditions + preconditions || [], + postconditions || [] ); - - // Generate helper lemmas - const lemmas = this.generateHelperLemmas(invariants, edgeCases); - - // Generate proof skeleton - const proofSkeleton = this.generateProofSkeleton(functionName, inputTypes, outputType); - - // Generate proof tactics - const proofTactics = this.generateProofTactics(functionName, preconditions, postconditions); - - // Generate security and performance lemmas + const lemmas = this.generateHelperLemmas(invariants || [], edgeCases || []); + const proofSkeleton = this.generateProofSkeleton(functionName, inputTypes || [], outputType); + const proofTactics = this.generateProofTactics(functionName, preconditions || [], postconditions || []); const securityLemmas = this.generateSecurityLemmas(security); const performanceLemmas = this.generatePerformanceLemmas(complexity); + const closedLemmas = this.generateClosedLemmas(functionName, inputTypes || [], outputType, preconditions || []); return { typeDefinitions: typeDefs, auxiliaryDefinitions: auxDefs, theorem: theorem, helperLemmas: lemmas, + closedLemmas, securityLemmas: securityLemmas, performanceLemmas: performanceLemmas, proofSkeleton: proofSkeleton, @@ -117,459 +132,691 @@ structure User where } /** - * Generate type definitions for Lean4 - * @param {Array} inputTypes - Input parameter types - * @param {string} outputType - Output type - * @returns {string} Type definitions + * Example/default values — never `sorry`. Omitted when empty inputs. */ - generateTypeDefinitions(inputTypes, outputType) { - let definitions = []; + generateTypeDefinitions(functionName: string, inputTypes: InputType[], outputType: string) { + if (!inputTypes || inputTypes.length === 0) { + return "-- (no example bindings; parameters appear on the opaque declaration)"; + } - // Generate input parameter types + const fn = this.sanitizeIdent(functionName || "fn"); + const definitions: string[] = []; for (const input of inputTypes) { const leanType = this.mapTypeToLean(input.type); - definitions.push(`def ${input.name} : ${leanType} := sorry`); + const value = this.inhabitedValue(leanType); + if (value === null) { + definitions.push(`-- SpecSync: skip example binding for structured type ${leanType}`); + continue; + } + definitions.push( + `/-- SpecSync: example binding (Inhabited default, not a proof). -/\ndef ${fn}_${this.sanitizeIdent(input.name)}_example : ${leanType} :=\n ${value}` + ); } - // Generate output type const outputLeanType = this.mapTypeToLean(outputType); - definitions.push(`def expected_output : ${outputLeanType} := sorry`); + const outVal = this.inhabitedValue(outputLeanType); + if (outVal === null) { + definitions.push(`-- SpecSync: skip expected_output example for structured type ${outputLeanType}`); + } else { + definitions.push( + `/-- SpecSync: example expected output binding. -/\ndef ${fn}_expected_output_example : ${outputLeanType} :=\n ${outVal}` + ); + } - return definitions.join("\n"); + return definitions.join("\n\n"); } - /** - * Generate auxiliary definitions for complex types - * @param {Array} inputTypes - Input parameter types - * @param {string} outputType - Output type - * @returns {string} Auxiliary definitions - */ - generateAuxiliaryDefinitions(inputTypes, outputType) { - const definitions = []; - const usedTypes = new Set(); + generateAuxiliaryDefinitions(inputTypes: InputType[], outputType: string) { + const definitions: string[] = []; + const usedTypes = new Set(); - // Collect all types used - inputTypes.forEach((input) => { + (inputTypes || []).forEach((input) => { if (this.complexTypeMap[input.type]) { usedTypes.add(input.type); } }); - if (this.complexTypeMap[outputType]) { + if (outputType && this.complexTypeMap[outputType]) { usedTypes.add(outputType); } - // Generate definitions for used types usedTypes.forEach((type) => { if (this.auxiliaryDefinitions[type]) { - definitions.push(this.auxiliaryDefinitions[type]); + definitions.push(this.auxiliaryDefinitions[type].trim()); } }); - return definitions.join("\n\n"); + return definitions.length > 0 + ? definitions.join("\n\n") + : "-- (no auxiliary structures for these types)"; } /** - * Generate enhanced theorem statement with better quantification - * @param {string} functionName - Function name - * @param {Array} inputTypes - Input types - * @param {string} outputType - Output type - * @param {Array} preconditions - Preconditions - * @param {Array} postconditions - Postconditions - * @returns {string} Enhanced theorem statement + * Main contract: opaque function + closed fragments + labeled sorry only when needed. */ - generateTheoremStatement(functionName, inputTypes, outputType, preconditions, postconditions) { - const params = inputTypes - .map((input) => `${input.name} : ${this.mapTypeToLean(input.type)}`) - .join(" "); + generateTheoremStatement( + functionName: string, + inputTypes: InputType[], + outputType: string, + preconditions: string[], + postconditions: string[] + ) { + const fn = this.sanitizeIdent(functionName || "fn"); + const params = this.formatBinders(inputTypes); + const args = this.formatArgs(inputTypes); const outputLeanType = this.mapTypeToLean(outputType); + const opaqueDecl = + params.length > 0 + ? `opaque ${fn} ${params.join(" ")} : ${outputLeanType}` + : `opaque ${fn} : ${outputLeanType}`; + + const preFormal = (preconditions || []).map((p) => this.formalizePredicate(p, "precondition")); + const postFormal = (postconditions || []).map((p) => this.formalizePredicate(p, "postcondition")); + + const preProp = this.joinProps(preFormal.map((p) => this.predToLean(p))); + const call = args.length > 0 ? `${fn} ${args.join(" ")}` : fn; + + const parts: string[] = []; + parts.push(`/-- SpecSync: function under contract; body not synthesized from source. -/`); + parts.push(opaqueDecl); + parts.push(""); + parts.push(`/-- Always-closed smoke obligation for Lake/CI. -/`); + parts.push(`theorem ${fn}_specsync_smoke : True := trivial`); + + // Closed: Nat-returning functions are nonnegative regardless of opaque body. + if (outputLeanType === "Nat") { + const binders = params.length > 0 ? ` ${params.join(" ")}` : ""; + parts.push(""); + parts.push(`/-- Closed: Nat results are nonnegative. -/`); + parts.push( + `theorem ${fn}_result_nonneg${binders} :\n ${call} >= 0 :=\n Nat.zero_le (${call})` + ); + } - // Convert preconditions to Lean4 predicates - const precondPredicates = this.convertToLeanPredicates(preconditions); - const postcondPredicates = this.convertToLeanPredicates(postconditions); + const proveablePosts = postFormal.filter((p) => this.isClosedFromOpaque(p, outputLeanType)); + const openPosts = postFormal.filter((p) => !this.isClosedFromOpaque(p, outputLeanType)); + + proveablePosts.forEach((p, index) => { + const goal = this.predToLean(this.bindResultPred(p, call, outputLeanType)); + if (!goal || goal === "True") return; + const binders = params.length > 0 ? ` ${params.join(" ")}` : ""; + const proof = this.closedProofFor(p, call, outputLeanType); + parts.push(""); + parts.push(`/-- Closed from type / decidable fragment. -/`); + parts.push(`theorem ${fn}_post_closed_${index + 1}${binders} :\n ${goal} :=\n ${proof}`); + }); - // Enhanced theorem with better structure - const theorem = `@[spec] -theorem ${functionName}_spec (${params}) : - ∀ (h_pre : ${precondPredicates.join(" ∧ ")}) : - let result := ${functionName} ${inputTypes.map((i) => i.name).join(" ")}; - ${postcondPredicates.join(" ∧ ")} ∧ - result : ${outputLeanType} := by - -- Proof tactics will be generated here - sorry`; + if (openPosts.length === 0 && proveablePosts.length === 0 && (postconditions || []).length === 0) { + parts.push(""); + parts.push(`/-- No postconditions supplied; contract is documentation-only. -/`); + parts.push(`theorem ${fn}_spec : True := trivial`); + return parts.join("\n"); + } - return theorem; - } + if (openPosts.length > 0) { + const postProps = openPosts.map((p) => this.predToLean(this.bindResultPred(p, call, outputLeanType))); + const postProp = this.joinProps(postProps); + const reasons = openPosts.map((p) => (p.kind === "raw" ? p.reason : `undecided ${p.kind}`)).join("; "); + const binders = params.length > 0 ? ` ${params.join(" ")}` : ""; + const preBinder = + preProp && preProp !== "True" + ? ` (_h_pre : ${preProp})` + : ""; + + parts.push(""); + // Never `sorry` a vacuous `True` goal — use an opaque Prop obligation instead. + if (!postProp || postProp === "True") { + parts.push(`/-- SpecSync: NL postconditions not yet a checkable Prop (see module doc). -/`); + parts.push(`opaque ${fn}_contract_holds : Prop`); + parts.push( + `theorem ${fn}_spec${binders}${preBinder} :\n ${fn}_contract_holds := by\n ${this.unprovedSorry(reasons || "NL postconditions not autoformalized")}` + ); + } else { + parts.push(`/-- SpecSync contract fragment not autoformalizable from opaque stub. -/`); + parts.push( + `theorem ${fn}_spec${binders}${preBinder} :\n ${postProp} := by\n ${this.unprovedSorry(reasons || "postcondition depends on unimplemented body")}` + ); + } + } else { + // All posts closed separately; emit a trivial umbrella theorem (no unused binders). + parts.push(""); + parts.push(`/-- All supplied postcondition fragments were closed above. -/`); + parts.push(`theorem ${fn}_spec : True := trivial`); + } - /** - * Generate proof tactics for the theorem - * @param {string} functionName - Function name - * @param {Array} preconditions - Preconditions - * @param {Array} postconditions - Postconditions - * @returns {string} Proof tactics - */ - generateProofTactics(functionName, preconditions, postconditions) { - const tactics = []; + return parts.join("\n"); + } - // Add basic tactics - tactics.push("-- Basic proof structure"); - tactics.push("intro h_pre"); - tactics.push("unfold ${functionName}"); + /** Honest label for unfinished proofs (soft gate reports these). */ + unprovedSorry(reason: string = "obligation not discharged") { + const safe = String(reason).replace(/\*\//g, "* /").replace(/\n/g, " ").slice(0, 200); + return `/- SpecSync: unproved: ${safe} -/\n sorry`; + } - // Add tactics for preconditions - if (preconditions.length > 0) { - tactics.push("-- Handle preconditions"); - preconditions.forEach((pre, index) => { - tactics.push(`have h_pre_${index} := h_pre.${index + 1}`); - }); - } + /** Count labeled unproved markers in generated Lean source. */ + countUnproved(leanSource: string) { + const matches = String(leanSource || "").match(/\/- SpecSync: unproved\b[\s\S]*?-\//g); + return matches ? matches.length : 0; + } - // Add tactics for postconditions - if (postconditions.length > 0) { - tactics.push("-- Prove postconditions"); - postconditions.forEach((post, index) => { - tactics.push(`-- TODO: Prove ${post}`); - tactics.push("sorry"); - }); + /** Count actual `sorry` tokens outside comments (CI / strict gate). */ + countSorry(leanSource: string) { + let text = String(leanSource || ""); + let prev: string | null = null; + while (prev !== text) { + prev = text; + text = text.replace(/\/-[\s\S]*?-\//, " "); } - - return tactics.join("\n "); + text = text.replace(/--[^\n]*/g, " "); + const matches = text.match(SORRY_TOKEN_RE); + return matches ? matches.length : 0; } /** - * Generate helper lemmas for invariants and edge cases - * @param {Array} invariants - Invariants - * @param {Array} edgeCases - Edge cases - * @returns {Array} Helper lemmas + * Soft proof gate: report sorry count; fail only when SPECSYNC_PROOF_GATE=strict. + * Strict fails on any `sorry` in generated/committed Lean source. */ - generateHelperLemmas(invariants, edgeCases) { - const lemmas = []; - - // Generate invariant lemmas with better naming - for (let i = 0; i < invariants.length; i++) { - const invariant = invariants[i]; - const lemmaName = `invariant_${i + 1}_${this.sanitizeName(invariant)}`; - const leanPredicate = this.convertToLeanPredicates([invariant])[0]; + evaluateProofGate(leanSource: string) { + const unproved = this.countUnproved(leanSource); + const sorry = this.countSorry(leanSource); + const mode = (process.env.SPECSYNC_PROOF_GATE || "soft").toLowerCase(); + const ok = mode !== "strict" || sorry === 0; + return { + unproved, + sorry, + mode, + ok, + message: + sorry === 0 + ? "No sorry goals" + : `${sorry} sorry goal(s) (${unproved} labeled SpecSync unproved); gate=${mode}`, + }; + } - lemmas.push(`lemma ${lemmaName} : ${leanPredicate} := sorry`); - } + /** + * Proof tactics as comments only (never top-level invalid Lean / sorry). + */ + generateProofTactics(functionName: string, preconditions: string[], postconditions: string[]) { + const lines = [ + `-- Suggested proof outline for ${this.sanitizeIdent(functionName)} (comments only)`, + `-- 1. intro hypotheses for preconditions`, + `-- 2. unfold / cases on the implementation once extracted`, + `-- 3. discharge decidable fragments with decide / simp / omega`, + ]; + (preconditions || []).forEach((pre, index) => { + lines.push(`-- precondition ${index + 1}: ${String(pre).replace(/\n/g, " ")}`); + }); + (postconditions || []).forEach((post, index) => { + lines.push(`-- postcondition ${index + 1}: ${String(post).replace(/\n/g, " ")}`); + }); + return lines.join("\n"); + } - // Generate edge case lemmas - for (let i = 0; i < edgeCases.length; i++) { - const edgeCase = edgeCases[i]; - const lemmaName = `edge_case_${i + 1}_${this.sanitizeName(edgeCase)}`; - const leanPredicate = this.convertToLeanPredicates([edgeCase])[0]; + /** + * NL invariants / edge cases → documentation comments (not fake Prop + sorry). + */ + generateHelperLemmas(invariants: string[], edgeCases: string[]) { + const lemmas: string[] = []; + + (invariants || []).forEach((invariant, i) => { + const formal = this.formalizePredicate(invariant, "invariant"); + if (formal.kind !== "raw" && this.canEmitStandaloneClosed(formal)) { + const name = `invariant_${i + 1}_${this.sanitizeName(String(invariant))}`; + lemmas.push(this.emitClosedStandaloneLemma(name, formal)); + } else { + lemmas.push( + `-- SpecSync invariant ${i + 1} (documentation only; not autoformalized):\n-- ${String(invariant).replace(/\n/g, " ")}` + ); + } + }); - lemmas.push(`lemma ${lemmaName} : ${leanPredicate} := sorry`); - } + (edgeCases || []).forEach((edgeCase, i) => { + lemmas.push( + `-- SpecSync edge case ${i + 1} (documentation only; not autoformalized):\n-- ${String(edgeCase).replace(/\n/g, " ")}` + ); + }); return lemmas; } - /** - * Sanitize name for Lean4 identifier - * @param {string} name - Name to sanitize - * @returns {string} Sanitized name - */ - sanitizeName(name) { + sanitizeName(name: string) { return name .replace(/[^a-zA-Z0-9_]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") - .toLowerCase(); + .toLowerCase() + .slice(0, 60); } - /** - * Generate enhanced proof skeleton with better structure - * @param {string} functionName - Function name - * @param {Array} inputTypes - Input types - * @param {string} outputType - Output type - * @returns {string} Enhanced proof skeleton - */ - generateProofSkeleton(functionName, inputTypes, outputType) { - const params = inputTypes.map((i) => `${i.name} : ${this.mapTypeToLean(i.type)}`).join(" "); - - return `def ${functionName} (${params}) : ${this.mapTypeToLean(outputType)} := by - -- TODO: Implement function body - -- This is a skeleton for the actual implementation - -- The real implementation would be extracted from the source code - sorry`; + sanitizeIdent(name: string) { + const cleaned = String(name || "fn") + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); + if (!cleaned) return "fn"; + if (/^[0-9]/.test(cleaned)) return `fn_${cleaned}`; + return cleaned; } /** - * Map JavaScript/TypeScript types to Lean4 types - * @param {string} type - Type to map - * @returns {string} Lean4 type + * Opaque declaration — no sorry (spec-as-contract). */ - mapTypeToLean(type) { - const normalizedType = type.toLowerCase(); + generateProofSkeleton(functionName: string, inputTypes: InputType[], outputType: string) { + const fn = this.sanitizeIdent(functionName); + const params = this.formatBinders(inputTypes); + const outputLeanType = this.mapTypeToLean(outputType); + const binders = params.length > 0 ? ` ${params.join(" ")}` : ""; + return ( + `/-- SpecSync: opaque stub (spec-as-contract). Prefer this over unfinished proofs for missing bodies. -/\n` + + `opaque ${fn}_impl${binders} : ${outputLeanType}` + ); + } + + mapTypeToLean(type: string): string { + if (!type) return "String"; + const normalizedType = String(type).toLowerCase(); if (this.typeMap[normalizedType]) { return this.typeMap[normalizedType]; } - // Handle array types if (normalizedType.includes("array") || normalizedType.includes("[]")) { - const elementType = normalizedType.replace(/array|\[\]/g, "").trim(); + const elementType = normalizedType.replace(/array|\[\]/g, "").trim() || "Nat"; return `List ${this.mapTypeToLean(elementType)}`; } - // Handle object types if (normalizedType.includes("object") || normalizedType.includes("{}")) { - return "Object"; + return "String"; } - // Default to generic type - return "α"; + if (this.complexTypeMap[type]) { + return this.complexTypeMap[type]; + } + + // Avoid unbound type variables in generated files — use String as safe default. + return "String"; } /** - * Generate security lemmas for Lean4 - * @param {Object} security - Security analysis - * @returns {Array} Array of security lemmas + * Security notes as comments — prior lemmas referenced undefined types + sorry. */ - generateSecurityLemmas(security) { + generateSecurityLemmas(security: any) { if (!security || !security.vulnerabilities || security.vulnerabilities.length === 0) { return []; } - const lemmas = []; - - // Generate lemmas for each vulnerability - security.vulnerabilities.forEach((vuln, index) => { - const lemmaName = `security_lemma_${index + 1}`; - lemmas.push(`-- Security Lemma: ${vuln} -lemma ${lemmaName} : ∀ (input : InputType), - (isValidInput input) → - (isSecureInput input) := by - intro input - intro h_valid h_secure - -- Proof that input validation prevents vulnerability - sorry`); - }); - - return lemmas; + return security.vulnerabilities.map( + (vuln: any, index: number) => + `-- SpecSync security note ${index + 1} (documentation only):\n-- ${String(vuln).replace(/\n/g, " ")}` + ); } /** - * Generate performance lemmas for Lean4 - * @param {Object} complexity - Complexity analysis - * @returns {Array} Array of performance lemmas + * Performance notes as comments — prior lemmas were not valid Lean. */ - generatePerformanceLemmas(complexity) { + generatePerformanceLemmas(complexity: any) { if (!complexity) { return []; } - const lemmas = []; - - // Time complexity lemma + const lemmas: string[] = []; if (complexity.time) { - lemmas.push(`-- Performance Lemma: Time Complexity -lemma time_complexity_bound : ∀ (input : InputType), - (inputSize input ≤ n) → - (executionTime input ≤ ${complexity.time.replace("O", "f")} n) := by - intro input - intro h_size - -- Proof of time complexity bound - sorry`); + lemmas.push(`-- SpecSync performance note (time): ${String(complexity.time).replace(/\n/g, " ")}`); } - - // Space complexity lemma if (complexity.space) { - lemmas.push(`-- Performance Lemma: Space Complexity -lemma space_complexity_bound : ∀ (input : InputType), - (inputSize input ≤ n) → - (memoryUsage input ≤ ${complexity.space.replace("O", "g")} n) := by - intro input - intro h_size - -- Proof of space complexity bound - sorry`); + lemmas.push(`-- SpecSync performance note (space): ${String(complexity.space).replace(/\n/g, " ")}`); } - return lemmas; } - /** - * Convert natural language predicates to Lean4 predicates - * @param {Array} predicates - Natural language predicates - * @returns {Array} Lean4 predicates - */ - convertToLeanPredicates(predicates) { - return predicates.map((predicate) => { - // Convert common patterns - let leanPred = predicate; - - // Handle null/undefined checks - leanPred = leanPred.replace(/is not null\/undefined/g, "≠ none"); - leanPred = leanPred.replace(/is null\/undefined/g, "= none"); - - // Handle type checks - leanPred = leanPred.replace(/is of type (\w+)/g, ": $1"); - - // Handle comparisons - leanPred = leanPred.replace(/(\w+) > 0/g, "$1 > 0"); - leanPred = leanPred.replace(/(\w+) < 0/g, "$1 < 0"); - leanPred = leanPred.replace(/(\w+) >= 0/g, "$1 ≥ 0"); - leanPred = leanPred.replace(/(\w+) <= 0/g, "$1 ≤ 0"); - - // Handle string operations - leanPred = leanPred.replace(/(\w+)\.length > 0/g, '¬($1 = "")'); - leanPred = leanPred.replace(/(\w+)\.length === 0/g, '$1 = ""'); - - // Handle boolean operations - leanPred = leanPred.replace(/is true/g, "= true"); - leanPred = leanPred.replace(/is false/g, "= false"); - - // Handle function calls - leanPred = leanPred.replace(/(\w+)\(/g, "$1 "); - - return leanPred; + convertToLeanPredicates(predicates: any[]) { + return (predicates || []).map((predicate) => { + const formal = this.formalizePredicate(String(predicate), "predicate"); + return this.predToLean(formal); }); } - /** - * Generate complete Lean4 file content with enhanced structure - * @param {Object} spec - Specification - * @param {Object} context - Function context - * @returns {string} Complete Lean4 file - */ - generateLean4File(spec, context) { + generateLean4File(spec: any, context: any) { const lean4Content = this.generateLean4Theorem(spec, context); + const fn = context.functionName || "fn"; + const confidencePct = Math.round(normalizeConfidence(spec.confidence) * 100); + + const nlBlock = this.formatNlContractDoc(spec); return `-- SpecSync Generated Lean4 Specification --- Function: ${context.functionName} --- Confidence: ${spec.confidence}% +-- Function: ${fn} +-- Confidence: ${confidencePct}% -- Generated: ${new Date().toISOString()} --- Track C: Autoformalization Engine +-- Lake path: SPECS_DIR (default specs/) — Std only, no Mathlib +-- Mode: spec-as-contract (opaque stubs; unfinished goals use labeled unproved markers) +-- Unproved goals: /- SpecSync: unproved: -/ + +import Std -import Mathlib.Data.Nat.Basic -import Mathlib.Data.String.Basic -import Mathlib.Logic.Basic -import Mathlib.Data.Real.Basic -import Mathlib.Data.Bool.Basic +${nlBlock} -- Auxiliary Definitions ${lean4Content.auxiliaryDefinitions} --- Type Definitions +-- Example Bindings (Inhabited defaults; not proofs) ${lean4Content.typeDefinitions} --- Helper Lemmas -${lean4Content.helperLemmas.join("\n\n")} +-- Helper Notes / Closed Fragments +${(lean4Content.helperLemmas || []).join("\n\n") || "-- (none)"} --- Security Lemmas -${lean4Content.securityLemmas.join("\n\n")} +${(lean4Content.closedLemmas || []).join("\n\n")} --- Performance Lemmas -${lean4Content.performanceLemmas.join("\n\n")} +-- Security Notes +${(lean4Content.securityLemmas || []).join("\n") || "-- (none)"} --- Main Theorem +-- Performance Notes +${(lean4Content.performanceLemmas || []).join("\n") || "-- (none)"} + +-- Main Contract ${lean4Content.theorem} --- Proof Tactics +-- Proof Outline (comments only) ${lean4Content.proofTactics} --- Implementation Skeleton +-- Alternate Opaque Impl Name (spec-as-contract) ${lean4Content.proofSkeleton} -- End of SpecSync Generated Code`; } - /** - * Generate CI-ready Lean4 file with proper structure - * @param {Object} spec - Specification - * @param {Object} context - Function context - * @param {string} outputDir - Output directory - * @returns {string} File path - */ - async generateCIReadyLean4File(spec, context, outputDir = SPECS_DIR_DEFAULT) { + async generateCIReadyLean4File(spec: any, context: any, outputDir: string = SPECS_DIR_DEFAULT) { const { functionName } = context; - - // Create output directory if it doesn't exist const fs = require("fs-extra"); await fs.ensureDir(outputDir); - // Generate file name with CI-friendly naming - const fileName = `${functionName}_spec.lean`; + const fileName = `${this.sanitizeIdent(functionName)}_spec.lean`; const outputFilePath = path.join(outputDir, fileName); - - // Generate content with CI annotations const content = this.generateLean4File(spec, context); - // Add CI-specific annotations - const ciContent = content.replace( - "-- Track C: Autoformalization Engine", - `-- Track C: Autoformalization Engine --- CI Integration: This file will be compiled and tested in CI --- @[must_verify] -- Critical function requiring proof --- @[spec_coverage] -- Tracks spec coverage metrics` - ); + const ciContent = `-- CI Integration: compile with Lake (Std only) +-- @[spec_coverage] tracks accept-path artifacts under .specsync/ +${content}`; - // Write file await fs.writeFile(outputFilePath, ciContent); - return outputFilePath; } - /** - * Generate multiple CI-ready Lean4 files for a set of specifications - * @param {Array} specs - Array of specifications with contexts - * @param {string} outputDir - Output directory - * @returns {Array} Array of generated file paths - */ - async generateCIReadyLean4Files(specs, outputDir = SPECS_DIR_DEFAULT) { + async generateCIReadyLean4Files(specs: any[], outputDir: string = SPECS_DIR_DEFAULT) { const filePaths = []; - for (const { spec, context } of specs) { const filePath = await this.generateCIReadyLean4File(spec, context, outputDir); filePaths.push(filePath); } - return filePaths; } - /** - * Generate Lean4 file for a function - * @param {Object} spec - Specification - * @param {Object} context - Function context - * @param {string} outputDir - Output directory - * @returns {string} File path - */ - async generateLean4FileForFunction(spec, context, outputDir = SPECS_DIR_DEFAULT) { + async generateLean4FileForFunction(spec: any, context: any, outputDir: string = SPECS_DIR_DEFAULT) { const { functionName } = context; - - // Create output directory if it doesn't exist const fs = require("fs-extra"); await fs.ensureDir(outputDir); - // Generate file name - const fileName = `${functionName}_spec.lean`; + const fileName = `${this.sanitizeIdent(functionName)}_spec.lean`; const outputFilePath = path.join(outputDir, fileName); - - // Generate content const content = this.generateLean4File(spec, context); - - // Write file await fs.writeFile(outputFilePath, content); - return outputFilePath; } - /** - * Generate multiple Lean4 files for a set of specifications - * @param {Array} specs - Array of specifications with contexts - * @param {string} outputDir - Output directory - * @returns {Array} Array of generated file paths - */ - async generateLean4Files(specs, outputDir = SPECS_DIR_DEFAULT) { + async generateLean4Files(specs: any[], outputDir: string = SPECS_DIR_DEFAULT) { const filePaths = []; - for (const { spec, context } of specs) { const filePath = await this.generateLean4FileForFunction(spec, context, outputDir); filePaths.push(filePath); } - return filePaths; } + + // --- internals ----------------------------------------------------------- + + formatBinders(inputTypes: InputType[]): string[] { + return (inputTypes || []).map( + (input) => `(${this.sanitizeIdent(input.name)} : ${this.mapTypeToLean(input.type)})` + ); + } + + formatArgs(inputTypes: InputType[]): string[] { + return (inputTypes || []).map((input) => this.sanitizeIdent(input.name)); + } + + inhabitedValue(leanType: string): string | null { + if (leanType === "Nat" || leanType === "Int") return "0"; + if (leanType === "Bool") return "false"; + if (leanType === "String") return '""'; + if (leanType.startsWith("List ")) return "[]"; + // Structured types: skip example bindings (need well-typed mk; avoid fake default/sorry). + if (leanType === "Account" || leanType === "Money" || leanType === "Email" || leanType === "User") { + return null; + } + return '""'; + } + + formalizePredicate(raw: string, role: string): FormalPred { + const text = String(raw || "").trim(); + if (!text) return { kind: "true" }; + + let leanPred = text; + leanPred = leanPred.replace(/is not null\/undefined/gi, "≠ none"); + leanPred = leanPred.replace(/is null\/undefined/gi, "= none"); + leanPred = leanPred.replace(/is of type (\w+)/gi, ": $1"); + leanPred = leanPred.replace(/(\w+)\.length\s*>\s*0/g, '¬($1 = "")'); + leanPred = leanPred.replace(/(\w+)\.length\s*===\s*0/g, '$1 = ""'); + leanPred = leanPred.replace(/\bis true\b/gi, "= true"); + leanPred = leanPred.replace(/\bis false\b/gi, "= false"); + leanPred = leanPred.replace(/(\w+)\s*>=\s*0/g, "$1 ≥ 0"); + leanPred = leanPred.replace(/(\w+)\s*<=\s*0/g, "$1 ≤ 0"); + + const natGe = leanPred.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*(≥|>=)\s*0$/); + if (natGe) return { kind: "nat_ge_zero", name: natGe[1] }; + + const natGt = leanPred.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*>\s*0$/); + if (natGt) return { kind: "nat_gt_zero", name: natGt[1] }; + + const natCmp = leanPred.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*(>|>=|<|<=)\s*(\d+)$/); + if (natCmp) { + return { + kind: "nat_cmp", + name: natCmp[1], + op: natCmp[2] as ">" | ">=" | "<" | "<=", + rhs: natCmp[3], + }; + } + + const boolEq = leanPred.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(true|false)$/i); + if (boolEq) { + return { kind: "bool_eq", name: boolEq[1], value: boolEq[2].toLowerCase() === "true" }; + } + + const strNonempty = leanPred.match(/^¬\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*""\s*\)$/); + if (strNonempty) return { kind: "str_nonempty", name: strNonempty[1] }; + + const strEmpty = leanPred.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*""$/); + if (strEmpty) return { kind: "str_empty", name: strEmpty[1] }; + + if (/≠\s*none/.test(leanPred) || /!=\s*none/.test(leanPred)) { + const m = leanPred.match(/([A-Za-z_][A-Za-z0-9_]*)/); + if (m) return { kind: "ne_none", name: m[1] }; + } + if (/=\s*none/.test(leanPred)) { + const m = leanPred.match(/([A-Za-z_][A-Za-z0-9_]*)/); + if (m) return { kind: "eq_none", name: m[1] }; + } + + // "result is positive" / "result is boolean" style — try result > 0 + if (/result\s+is\s+positive/i.test(text) || /is\s+positive/i.test(text)) { + return { kind: "nat_gt_zero", name: "result" }; + } + if (/result\s+is\s+boolean/i.test(text)) { + return { kind: "true" }; + } + + return { + kind: "raw", + text: leanPred, + reason: `${role} not decidable/autoformalizable: ${text.slice(0, 120)}`, + }; + } + + predToLean(pred: FormalPred): string { + switch (pred.kind) { + case "true": + return "True"; + case "nat_ge_zero": + return `${pred.name} >= 0`; + case "nat_gt_zero": + return `${pred.name} > 0`; + case "nat_cmp": + return `${pred.name} ${pred.op} ${pred.rhs}`; + case "bool_eq": + return `${pred.name} = ${pred.value}`; + case "str_nonempty": + return `¬(${pred.name} = "")`; + case "str_empty": + return `${pred.name} = ""`; + case "ne_none": + // Option not modeled for plain params — keep as documentation-strength True? Prefer raw. + return "True"; + case "eq_none": + return "True"; + case "raw": + // Do not inject free-form NL into Lean Prop — use True placeholder in conjunctions + // and keep the real obligation behind labeled sorry at theorem level. + return `(True : Prop)`; + default: + return "True"; + } + } + + joinProps(props: string[]): string { + const cleaned = props.filter((p) => p && p !== "True" && p !== "(True : Prop)"); + if (cleaned.length === 0) return "True"; + return cleaned.join(" ∧ "); + } + + bindResultPred(pred: FormalPred, call: string, outputLeanType: string): FormalPred { + if (pred.kind === "nat_ge_zero" && pred.name === "result") { + return { kind: "nat_ge_zero", name: `(${call})` }; + } + if (pred.kind === "nat_gt_zero" && pred.name === "result") { + return { kind: "nat_gt_zero", name: `(${call})` }; + } + if (pred.kind === "nat_cmp" && pred.name === "result") { + return { ...pred, name: `(${call})` }; + } + if (pred.kind === "bool_eq" && pred.name === "result") { + return { ...pred, name: `(${call})` }; + } + if (pred.kind === "true" && outputLeanType === "Bool") { + return { kind: "true" }; + } + return pred; + } + + /** + * Obligations we can close without knowing the opaque body. + */ + isClosedFromOpaque(pred: FormalPred, outputLeanType: string): boolean { + if (pred.kind === "true") return true; + if (pred.kind === "nat_ge_zero") { + // ∀ n, n ≥ 0 on Nat — including opaque Nat results and Nat params + if (pred.name === "result" && outputLeanType === "Nat") return true; + // Parameter nonneg: only if we know it's Nat — assume when name is a binder we emit Nat + return pred.name !== "result"; + } + return false; + } + + closedProofFor(pred: FormalPred, call: string, outputLeanType: string): string { + if (pred.kind === "true") return "trivial"; + if (pred.kind === "nat_ge_zero") { + const target = pred.name === "result" || pred.name.startsWith("(") ? call : pred.name; + if (outputLeanType === "Nat" || pred.name !== "result") { + return `Nat.zero_le (${target})`; + } + } + return "trivial"; + } + + canEmitStandaloneClosed(pred: FormalPred): boolean { + return pred.kind === "nat_ge_zero" || pred.kind === "true"; + } + + emitClosedStandaloneLemma(name: string, pred: FormalPred): string { + if (pred.kind === "true") { + return `theorem ${name} : True := trivial`; + } + if (pred.kind === "nat_ge_zero") { + return ( + `theorem ${name} (${pred.name} : Nat) :\n` + + ` ${pred.name} >= 0 :=\n` + + ` Nat.zero_le ${pred.name}` + ); + } + return `-- skipped ${name}`; + } + + generateClosedLemmas( + functionName: string, + inputTypes: InputType[], + _outputType: string, + preconditions: string[] + ): string[] { + const fn = this.sanitizeIdent(functionName); + const lemmas: string[] = []; + const natParams = (inputTypes || []).filter((i) => this.mapTypeToLean(i.type) === "Nat"); + + natParams.forEach((input) => { + const n = this.sanitizeIdent(input.name); + lemmas.push( + `/-- Closed: Nat parameters are nonnegative. -/\n` + + `theorem ${fn}_param_${n}_nonneg (${n} : Nat) :\n` + + ` ${n} >= 0 :=\n` + + ` Nat.zero_le ${n}` + ); + }); + + // If a precondition is exactly `x > 0`, emit a closed lemma x ≠ 0 from that hyp. + (preconditions || []).forEach((pre, index) => { + const formal = this.formalizePredicate(pre, "precondition"); + if (formal.kind === "nat_gt_zero") { + const n = this.sanitizeIdent(formal.name); + lemmas.push( + `/-- Closed: positivity implies inequality with zero. -/\n` + + `theorem ${fn}_pre_${index + 1}_pos_ne (${n} : Nat) (h : ${n} > 0) :\n` + + ` ${n} ≠ 0 :=\n` + + ` Nat.pos_iff_ne_zero.mp h` + ); + } + }); + + return lemmas; + } + + formatNlContractDoc(spec: any): string { + const lines = ["/-!", " ## Natural-language contract (not all formalized)", ""]; + const section = (title: string, items: any[]) => { + lines.push(` ### ${title}`); + if (!items || items.length === 0) { + lines.push(" - (none)"); + } else { + items.forEach((item) => lines.push(` - ${String(item).replace(/\n/g, " ")}`)); + } + lines.push(""); + }; + section("Preconditions", spec.preconditions || spec.contract?.preconditions); + section("Postconditions", spec.postconditions || spec.contract?.postconditions); + section("Invariants", spec.invariants || spec.contract?.invariants); + section("Edge cases", spec.edgeCases || spec.contract?.edgeCases); + if (spec.reasoning) { + lines.push(" ### Reasoning"); + lines.push(` ${String(spec.reasoning).replace(/\n/g, " ")}`); + lines.push(""); + } + lines.push("-/"); + return lines.join("\n"); + } } diff --git a/src/llm-client.ts b/src/llm-client.ts index 1beda1a..17317fb 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -1,9 +1,20 @@ -// @ts-nocheck const OpenAI = require("openai"); const Anthropic = require("@anthropic-ai/sdk"); +const { normalizeConfidence } = require("./types"); +const { incr } = require("./metrics"); +const { logger } = require("./logger"); require("dotenv").config(); +/** Sentinel when production denies mock LLM output. */ +export const LLM_UNAVAILABLE = Object.freeze({ + unavailable: true, + reason: "llm_unavailable", + isMock: false, +}); + export class LLMClient { + openai: any; + anthropic: any; constructor() { this.openai = null; this.anthropic = null; @@ -42,25 +53,64 @@ export class LLMClient { } } + /** + * Whether a live LLM provider client is configured. + * @returns {boolean} + */ + hasLiveClients() { + return Boolean(this.openai || this.anthropic); + } + + /** + * Mock LLM is allowed in non-production, or when SPECSYNC_ALLOW_MOCK_LLM=true. + * In production with the flag unset/false, mock specs must not be posted. + * @returns {boolean} + */ + isMockLlmAllowed() { + if (process.env.SPECSYNC_ALLOW_MOCK_LLM === "true") { + return true; + } + return process.env.NODE_ENV !== "production"; + } + /** * Generate specification using available LLM * @param {Object} context - Function analysis context - * @returns {Object} Generated specification + * @returns {Object} Generated specification, or unavailable sentinel when mock denied */ - async generateSpec(context) { + async generateSpec(context: any) { try { - // Try OpenAI first, then Anthropic, then fallback to mock + // Try OpenAI first, then Anthropic, then fallback to mock (if allowed) if (this.openai) { - return await this.generateWithOpenAI(context); + const result = await this.generateWithOpenAI(context); + incr("llm_live"); + return result; } else if (this.anthropic) { - return await this.generateWithAnthropic(context); + const result = await this.generateWithAnthropic(context); + incr("llm_live"); + return result; } else { - return this.generateMockSpec(context); + return this.mockOrUnavailable(context); } - } catch (error) { - console.error("Error generating spec with LLM:", error); - return this.generateMockSpec(context); + } catch (error: unknown) { + incr("llm_failures"); + logger.error({ err: error }, "Error generating spec with LLM"); + return this.mockOrUnavailable(context); + } + } + + /** + * Return a mock spec when allowed; otherwise the unavailable sentinel. + * @param {Object} context - Function analysis context + * @returns {Object} + */ + mockOrUnavailable(context: any) { + if (!this.isMockLlmAllowed()) { + incr("llm_failures"); + return { ...LLM_UNAVAILABLE }; } + incr("llm_mock"); + return this.generateMockSpec(context); } /** @@ -68,7 +118,7 @@ export class LLMClient { * @param {Object} context - Function analysis context * @returns {Object} Generated specification */ - async generateWithOpenAI(context) { + async generateWithOpenAI(context: any) { const prompt = this.buildPrompt(context); const response = await this.openai.chat.completions.create({ @@ -97,7 +147,7 @@ export class LLMClient { * @param {Object} context - Function analysis context * @returns {Object} Generated specification */ - async generateWithAnthropic(context) { + async generateWithAnthropic(context: any) { const prompt = this.buildPrompt(context); const response = await this.anthropic.messages.create({ @@ -121,7 +171,7 @@ export class LLMClient { * @param {Object} context - Function analysis context * @returns {string} Enhanced LLM prompt */ - buildPrompt(context) { + buildPrompt(context: any) { return `You are an expert software engineer specializing in formal specification and program verification. Analyze the following ${context.language} function and generate a comprehensive formal specification using state-of-the-art techniques. Function: ${context.functionName} @@ -184,7 +234,7 @@ Please generate a formal specification using the following advanced analysis fra - Memory leak potential - API contract compliance -6. CONFIDENCE: Your confidence level (0-100) in this analysis +6. CONFIDENCE: Your confidence level as a number in [0, 1] (e.g. 0.85) - Consider the complexity of the function - Consider the clarity of the implementation - Consider the presence of comments/documentation @@ -204,7 +254,7 @@ Respond with ONLY valid JSON in this exact format: "vulnerabilities": ["list", "of", "potential", "issues"], "mitigations": ["list", "of", "mitigation", "strategies"] }, - "confidence": 85, + "confidence": 0.85, "reasoning": "detailed explanation of the analysis including key insights and potential concerns" }`; } @@ -214,7 +264,7 @@ Respond with ONLY valid JSON in this exact format: * @param {string} response - LLM response * @returns {Object} Parsed specification */ - parseLLMResponse(response) { + parseLLMResponse(response: string) { try { // Try to extract JSON from the response const jsonMatch = response.match(/\{[\s\S]*\}/); @@ -229,7 +279,7 @@ Respond with ONLY valid JSON in this exact format: "edgeCases", "confidence", ]; - const missing = required.filter((field) => !parsed[field]); + const missing = required.filter((field: any) => !parsed[field]); if (missing.length === 0) { return { @@ -239,26 +289,28 @@ Respond with ONLY valid JSON in this exact format: edgeCases: Array.isArray(parsed.edgeCases) ? parsed.edgeCases : [], complexity: parsed.complexity || { time: "O(1)", space: "O(1)" }, security: parsed.security || { vulnerabilities: [], mitigations: [] }, - confidence: typeof parsed.confidence === "number" ? parsed.confidence : 50, + confidence: normalizeConfidence(parsed.confidence, 0.5), reasoning: parsed.reasoning || "Generated by LLM", + isMock: false, }; } } throw new Error("Invalid JSON response"); - } catch (error) { + } catch (error: unknown) { console.error("Failed to parse LLM response:", error); console.log("Raw response:", response); - return this.generateMockSpec({}); + return this.mockOrUnavailable({}); } } /** - * Generate mock specification for testing/fallback + * Generate mock specification for testing/fallback. + * Always sets isMock: true so callers can badge or gate posting. * @param {Object} context - Function analysis context * @returns {Object} Mock specification */ - generateMockSpec(context) { + generateMockSpec(context: any) { const { inputTypes = [], conditionalGuards = [], earlyReturns = [], complexity = 1 } = context; const preconditions = []; @@ -317,8 +369,9 @@ Respond with ONLY valid JSON in this exact format: "Validate all inputs", ], }, - confidence: Math.min(85, 100 - complexity * 5), + confidence: normalizeConfidence(Math.min(0.85, 1 - complexity * 0.05)), reasoning: `Mock analysis of function with ${inputTypes.length} inputs, ${conditionalGuards.length} conditionals, and complexity ${complexity}. Advanced security and performance analysis included.`, + isMock: true, }; } @@ -341,8 +394,8 @@ Respond with ONLY valid JSON in this exact format: max_tokens: 10, }); results.openai = true; - } catch (error) { - console.error("OpenAI test failed:", error.message); + } catch (error: unknown) { + console.error("OpenAI test failed:", error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error)); } } @@ -354,8 +407,8 @@ Respond with ONLY valid JSON in this exact format: messages: [{ role: "user", content: "Hello" }], }); results.anthropic = true; - } catch (error) { - console.error("Anthropic test failed:", error.message); + } catch (error: unknown) { + console.error("Anthropic test failed:", error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error)); } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..a03e7cd --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,12 @@ +/** + * Shared pino logger for non-Probot paths (CLI, queue internals, LLM). + * Prefer context.log inside Probot handlers so request IDs stay correlated. + */ +import pino from "pino"; + +export const logger = pino({ + name: "specsync", + level: process.env.LOG_LEVEL || "info", +}); + +export type SpecSyncLogger = typeof logger; diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..8074a64 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,52 @@ +/** + * Lightweight in-process metrics counters. + * Values are also emitted via structured logs; no external backend required. + */ +import { logger } from "./logger"; + +export type MetricName = + | "specs_posted" + | "specs_accepted" + | "specs_ignored" + | "specs_edited" + | "drifts_detected" + | "llm_failures" + | "llm_mock" + | "llm_live" + | "analysis_jobs_completed" + | "analysis_jobs_failed" + | "commands_rate_limited"; + +const counters: Record = { + specs_posted: 0, + specs_accepted: 0, + specs_ignored: 0, + specs_edited: 0, + drifts_detected: 0, + llm_failures: 0, + llm_mock: 0, + llm_live: 0, + analysis_jobs_completed: 0, + analysis_jobs_failed: 0, + commands_rate_limited: 0, +}; + +export function incr(metric: MetricName, by = 1): number { + counters[metric] = (counters[metric] ?? 0) + by; + return counters[metric]; +} + +export function getMetrics(): Readonly> { + return { ...counters }; +} + +export function resetMetricsForTests(): void { + for (const key of Object.keys(counters) as MetricName[]) { + counters[key] = 0; + } +} + +/** Log a counter snapshot (for /ready or job completion). */ +export function logMetricsSnapshot(extra?: Record): void { + logger.info({ metrics: getMetrics(), ...extra }, "specsync.metrics"); +} diff --git a/src/slack-alerts.ts b/src/slack-alerts.ts index 5b2683e..9f28c98 100644 --- a/src/slack-alerts.ts +++ b/src/slack-alerts.ts @@ -1,7 +1,11 @@ -// @ts-nocheck const { WebClient } = require("@slack/web-api"); export class SlackAlerts { + slack: any; + channel: any; + botToken: any; + silencedAlerts: any; + threadTimestamps: any; constructor() { this.slack = null; this.channel = process.env.SLACK_CHANNEL || "#specsync"; @@ -27,7 +31,7 @@ export class SlackAlerts { * Inputs: drift_events.json, CODEOWNERS mapping. * Explicit Outputs: Actionable alert with Review and Silence buttons */ - async sendDriftAlert(driftEvent) { + async sendDriftAlert(driftEvent: any) { if (!this.slack) { console.log("Slack not configured, skipping drift alert"); return; @@ -52,7 +56,7 @@ export class SlackAlerts { console.log(`Drift alert sent: ${result.ts}`); return result; - } catch (error) { + } catch (error: unknown) { console.error("Failed to send drift alert:", error); throw error; } @@ -63,27 +67,27 @@ export class SlackAlerts { * @param {Object} driftEvent - Drift event object * @returns {Object} Formatted message */ - formatDriftMessage(driftEvent) { + formatDriftMessage(driftEvent: any) { const severityEmoji = { high: "🔴", medium: "🟡", low: "🟢", }; - const severityColor = { + const severityColor: Record = { high: "#dc3545", medium: "#ffc107", low: "#28a745", }; - const text = `${severityEmoji[driftEvent.severity]} Drift detected in \`${driftEvent.functionName}\` (module ${driftEvent.module}).\nReason: ${driftEvent.reason}`; + const text = `${(severityEmoji as Record)[driftEvent.severity] || "⚪"} Drift detected in \`${driftEvent.functionName}\` (module ${driftEvent.module}).\nReason: ${driftEvent.reason}`; const blocks = [ { type: "header", text: { type: "plain_text", - text: `${severityEmoji[driftEvent.severity]} Spec Drift Detected`, + text: `${(severityEmoji as Record)[driftEvent.severity] || "⚪"} Spec Drift Detected`, emoji: true, }, }, @@ -170,7 +174,7 @@ export class SlackAlerts { * Inputs: GitHub Action failure payload. * Explicit Outputs: Red alert with View Logs button and thread updates */ - async sendProofFailureAlert(proofFailure) { + async sendProofFailureAlert(proofFailure: any) { if (!this.slack) { console.log("Slack not configured, skipping proof failure alert"); return; @@ -192,7 +196,7 @@ export class SlackAlerts { this.storeThreadTimestamp(proofFailure.prNumber, result.ts); return result; - } catch (error) { + } catch (error: unknown) { console.error("Failed to send proof failure alert:", error); throw error; } @@ -203,7 +207,7 @@ export class SlackAlerts { * @param {Object} proofFailure - Proof failure object * @returns {Object} Formatted message */ - formatProofFailureMessage(proofFailure) { + formatProofFailureMessage(proofFailure: any) { const text = `🔴 Proof failed for PR #${proofFailure.prNumber}.\nFunction: ${proofFailure.functionName}\nLean error: ${proofFailure.error}`; const blocks = [ @@ -292,7 +296,7 @@ export class SlackAlerts { * @param {string} prNumber - PR number * @param {string} threadTs - Thread timestamp */ - storeThreadTimestamp(prNumber, threadTs) { + storeThreadTimestamp(prNumber: string, threadTs: string) { if (!this.threadTimestamps) { this.threadTimestamps = new Map(); } @@ -304,7 +308,7 @@ export class SlackAlerts { * @param {string} prNumber - PR number * @returns {string|null} Thread timestamp */ - getThreadTimestamp(prNumber) { + getThreadTimestamp(prNumber: string) { return this.threadTimestamps ? this.threadTimestamps.get(prNumber) : null; } @@ -312,7 +316,7 @@ export class SlackAlerts { * Enhanced handle interactive messages with improved error handling * @param {Object} payload - Slack interactive message payload */ - async handleInteractiveMessage(payload) { + async handleInteractiveMessage(payload: any) { if (payload.type === "block_actions") { for (const action of payload.actions) { if (action.action_id === "silence_drift_alert") { @@ -327,7 +331,7 @@ export class SlackAlerts { * @param {string} value - JSON string with alert details * @param {Object} payload - Slack payload */ - async handleSilenceAlert(value, payload) { + async handleSilenceAlert(value: string, payload: any) { try { const { alertKey, duration } = JSON.parse(value); @@ -345,7 +349,7 @@ export class SlackAlerts { }); console.log(`Alert silenced: ${alertKey}`); - } catch (error) { + } catch (error: unknown) { console.error("Error handling silence alert:", error); } } @@ -355,7 +359,7 @@ export class SlackAlerts { * @param {string} alertKey - Alert key * @returns {boolean} Is silenced */ - isAlertSilenced(alertKey) { + isAlertSilenced(alertKey: string) { const silenced = this.silencedAlerts.get(alertKey); if (!silenced) return false; @@ -385,7 +389,7 @@ export class SlackAlerts { * @param {string} threadTs - Thread timestamp * @param {Object} statusUpdate - Status update object */ - async sendThreadUpdate(threadTs, statusUpdate) { + async sendThreadUpdate(threadTs: string, statusUpdate: any) { if (!this.slack) return; const message = this.formatStatusUpdateMessage(statusUpdate); @@ -397,7 +401,7 @@ export class SlackAlerts { text: message.text, blocks: message.blocks, }); - } catch (error) { + } catch (error: unknown) { console.error("Failed to send thread update:", error); } } @@ -407,7 +411,7 @@ export class SlackAlerts { * @param {Object} statusUpdate - Status update object * @returns {Object} Formatted message */ - formatStatusUpdateMessage(statusUpdate) { + formatStatusUpdateMessage(statusUpdate: any) { const emoji = statusUpdate.success ? "✅" : "❌"; const text = `${emoji} Proof ${statusUpdate.success ? "passed" : "failed"}: ${statusUpdate.functionName}`; @@ -438,7 +442,7 @@ export class SlackAlerts { * Send coverage summary alert * @param {Object} coverageSummary - Coverage summary object */ - async sendCoverageSummary(coverageSummary) { + async sendCoverageSummary(coverageSummary: any) { if (!this.slack) return; const message = this.formatCoverageSummaryMessage(coverageSummary); @@ -449,7 +453,7 @@ export class SlackAlerts { text: message.text, blocks: message.blocks, }); - } catch (error) { + } catch (error: unknown) { console.error("Failed to send coverage summary:", error); } } @@ -459,7 +463,7 @@ export class SlackAlerts { * @param {Object} coverageSummary - Coverage summary object * @returns {Object} Formatted message */ - formatCoverageSummaryMessage(coverageSummary) { + formatCoverageSummaryMessage(coverageSummary: any) { const { coverage, totalFunctions, coveredFunctions, failedProofs } = coverageSummary; const emoji = coverage >= 70 ? "🟢" : coverage >= 50 ? "🟡" : "🔴"; @@ -518,7 +522,7 @@ export class SlackAlerts { * Send drift resolution alert * @param {Object} driftResolution - Drift resolution object */ - async sendDriftResolution(driftResolution) { + async sendDriftResolution(driftResolution: any) { if (!this.slack) return; const message = this.formatDriftResolutionMessage(driftResolution); @@ -529,7 +533,7 @@ export class SlackAlerts { text: message.text, blocks: message.blocks, }); - } catch (error) { + } catch (error: unknown) { console.error("Failed to send drift resolution:", error); } } @@ -539,7 +543,7 @@ export class SlackAlerts { * @param {Object} driftResolution - Drift resolution object * @returns {Object} Formatted message */ - formatDriftResolutionMessage(driftResolution) { + formatDriftResolutionMessage(driftResolution: any) { const text = `✅ Drift resolved: ${driftResolution.functionName} in ${driftResolution.module}`; const blocks = [ @@ -613,10 +617,10 @@ export class SlackAlerts { user: result.user, channel: this.channel, }; - } catch (error) { + } catch (error: unknown) { return { connected: false, - error: error.message, + error: (error instanceof Error ? error.message : String(error)), }; } } diff --git a/src/spec-analyzer.ts b/src/spec-analyzer.ts index 1acaad6..4ac4808 100644 --- a/src/spec-analyzer.ts +++ b/src/spec-analyzer.ts @@ -1,82 +1,216 @@ -// @ts-nocheck -const fs = require("fs-extra"); -const path = require("path"); const { LLMClient } = require("./llm-client"); +const { specStore, hashImplementation } = require("./spec-store"); + +const DEFAULT_MAX_SPECS = 10; +const DEFAULT_CONCURRENCY = 3; + +/** + * Soft cost estimate: ~4 chars per token + fixed prompt overhead. + * Logged per job; not a billing meter. + */ +function estimateTokens(context: any) { + const bodyLen = (context.functionBody || "").length; + return Math.ceil(bodyLen / 4) + 800; +} + +async function mapWithConcurrency(items: any, concurrency: any, mapper: any) { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index], index); + } + } + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); + await Promise.all(workers); + return results; +} export class SpecAnalyzer { - constructor() { - this.llmClient = new LLMClient(); + llmClient: any; + specCache: any; + cacheTtlMs: any; + lastJobCost: any; + lastLlmUnavailable: any; + constructor(options: any= {}) { + this.llmClient = options.llmClient || new LLMClient(); this.specCache = new Map(); + this.cacheTtlMs = Number(process.env.SPECSYNC_CACHE_TTL_MS || 3_600_000); + this.lastJobCost = { estimatedTokens: 0, llmCalls: 0, cappedAt: 0 }; + /** Set when any LLM call returned the production unavailable sentinel. */ + this.lastLlmUnavailable = false; + } + + getMaxSpecsPerPr() { + const raw = Number(process.env.SPECSYNC_MAX_SPECS_PER_PR ?? DEFAULT_MAX_SPECS); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_SPECS; + } + + getConcurrency() { + const raw = Number(process.env.SPECSYNC_LLM_CONCURRENCY ?? DEFAULT_CONCURRENCY); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_CONCURRENCY; } /** - * Analyze changes and generate spec suggestions + * Analyze changes and generate spec suggestions (budgeted + concurrent). * @param {Array} astAnalysis - AST analysis results * @param {Object} context - GitHub context * @returns {Array} Array of spec suggestions */ - async analyzeChanges(astAnalysis, context) { - const specSuggestions = []; + async analyzeChanges(astAnalysis: any[], context: any) { + const maxSpecs = this.getMaxSpecsPerPr(); + const concurrency = this.getConcurrency(); + const eligible = Array.isArray(astAnalysis) ? astAnalysis : []; + const capped = eligible.slice(0, maxSpecs); + + this.lastJobCost = { + estimatedTokens: 0, + llmCalls: 0, + cappedAt: capped.length, + skippedByBudget: Math.max(0, eligible.length - capped.length), + }; - for (const analysis of astAnalysis) { - try { - const suggestion = await this.generateSpecSuggestion(analysis, context); - if (suggestion) { - specSuggestions.push(suggestion); + const owner = context?.payload?.repository?.owner?.login; + const repo = context?.payload?.repository?.name; + const ref = + context?.payload?.pull_request?.head?.sha || + context?.payload?.repository?.default_branch || + "main"; + + let filtered = capped; + if (owner && repo) { + const kept = []; + for (const analysis of capped) { + try { + const ignored = await specStore.isIgnored( + context.octokit, + { owner, repo, ref }, + analysis.filePath, + analysis.functionName + ); + if (!ignored) { + kept.push(analysis); + } + } catch { + kept.push(analysis); } - } catch (error) { + } + filtered = kept; + } + + this.lastLlmUnavailable = false; + + const suggestions = await mapWithConcurrency(filtered, concurrency, async (analysis: any) => { + try { + return await this.generateSpecSuggestion(analysis, context); + } catch (error: unknown) { console.error(`Error generating spec for ${analysis.functionName}:`, error); + return null; } + }); + + const result = []; + for (const suggestion of suggestions) { + if (!suggestion) { + continue; + } + if (suggestion.unavailable) { + this.lastLlmUnavailable = true; + continue; + } + result.push(suggestion); } - return specSuggestions; + console.log( + `[SpecSync] soft cost: ~${this.lastJobCost.estimatedTokens} tokens, ` + + `${this.lastJobCost.llmCalls} LLM calls, ` + + `${result.length} specs (cap ${maxSpecs}, skipped ${this.lastJobCost.skippedByBudget})` + + (this.lastLlmUnavailable ? ", llmUnavailable=true" : "") + ); + + return result; } /** - * Generate spec suggestion for a single function + * Generate a spec suggestion for a single function via the LLM client. + * Returns { unavailable: true } when production mock policy denies fallback. * @param {Object} analysis - AST analysis for a function * @param {Object} context - GitHub context - * @returns {Object} Spec suggestion + * @returns {Object|null} Spec suggestion or unavailable sentinel */ - async generateSpecSuggestion(analysis, context) { + async generateSpecSuggestion(analysis: any, context: any) { const { functionName, functionBody, ast, filePath, lineNumber } = analysis; - // Check cache first - const cacheKey = `${filePath}:${functionName}`; - if (this.specCache.has(cacheKey)) { - return this.specCache.get(cacheKey); + const cacheKey = this.buildCacheKey(context, filePath, functionName); + const cached = this.getCacheEntry(cacheKey); + if (cached) { + return cached; } - // Prepare context for LLM const llmContext = this.prepareLLMContext(analysis); + this.lastJobCost.estimatedTokens += estimateTokens(llmContext); + this.lastJobCost.llmCalls += 1; - // Generate spec using LLM (placeholder for now) const spec = await this.callLLM(llmContext); + if (spec?.unavailable) { + return { unavailable: true, reason: spec.reason || "llm_unavailable" }; + } + const suggestion = { functionName, filePath, lineNumber, + side: analysis.side === "LEFT" ? "LEFT" : "RIGHT", preconditions: spec.preconditions || [], postconditions: spec.postconditions || [], invariants: spec.invariants || [], edgeCases: spec.edgeCases || [], confidence: spec.confidence || 0, reasoning: spec.reasoning || "", + isMock: Boolean(spec.isMock), + implementationHash: hashImplementation(this.cleanFunctionBody(functionBody || "")), }; - // Cache the result - this.specCache.set(cacheKey, suggestion); - + this.setCacheEntry(cacheKey, suggestion); return suggestion; } + buildCacheKey(context: any, filePath: any, functionName: any) { + const repo = context?.payload?.repository?.full_name || "local"; + const sha = + context?.payload?.pull_request?.head?.sha || + context?.payload?.after || + "unknown"; + return `${repo}@${sha}:${filePath}:${functionName}`; + } + + getCacheEntry(key: any) { + const entry = this.specCache.get(key); + if (!entry) { + return null; + } + if (Date.now() > entry.expiresAt) { + this.specCache.delete(key); + return null; + } + return entry.value; + } + + setCacheEntry(key: any, value: any) { + this.specCache.set(key, { value, expiresAt: Date.now() + this.cacheTtlMs }); + } + /** * Prepare context for LLM analysis * @param {Object} analysis - AST analysis * @returns {Object} LLM context */ - prepareLLMContext(analysis) { + prepareLLMContext(analysis: any) { const { functionName, functionBody, ast, comments } = analysis; return { @@ -90,7 +224,7 @@ export class SpecAnalyzer { earlyReturns: ast.earlyReturns, controlFlow: ast.controlFlow, complexity: ast.complexity, - comments: comments.map((c) => c.text).join("\n"), + comments: (comments || []).map((c: any) => c.text).join("\n"), language: this.getLanguageFromPath(analysis.filePath), }; } @@ -100,35 +234,23 @@ export class SpecAnalyzer { * @param {string} functionBody - Raw function body * @returns {string} Cleaned function body */ - cleanFunctionBody(functionBody) { + cleanFunctionBody(functionBody: string) { return functionBody .split("\n") - .map((line) => line.replace(/^(\+|\-)?\s*/, "")) - .filter((line) => line.trim()) + .map((line: any) => line.replace(/^(\+|\-)?\s*/, "")) + .filter((line: any) => line.trim()) .join("\n"); } /** - * Get language from file path + * Get language from file path. + * Only tree-sitter-backed languages are named; others return "Unknown". * @param {string} filePath - File path * @returns {string} Language name */ - getLanguageFromPath(filePath) { - const ext = path.extname(filePath); - const languageMap = { - ".js": "JavaScript", - ".jsx": "JavaScript", - ".ts": "TypeScript", - ".tsx": "TypeScript", - ".py": "Python", - ".java": "Java", - - ".rs": "Rust", - ".cpp": "C++", - ".c": "C", - }; - - return languageMap[ext] || "Unknown"; + getLanguageFromPath(filePath: string) { + const { getLanguageNameFromPath } = require("./ast-extractor"); + return getLanguageNameFromPath(filePath); } /** @@ -136,36 +258,176 @@ export class SpecAnalyzer { * @param {Object} context - LLM context * @returns {Object} Generated spec */ - async callLLM(context) { + async callLLM(context: any) { return await this.llmClient.generateSpec(context); } /** - * Detect spec drift in changed functions - * @param {Array} commits - Array of commits + * Detect spec drift: compare current implementations to accepted `.specsync/` snapshots. + * @param {Array} commits - Array of commits (push payload) * @param {Object} context - GitHub context - * @returns {Array} Drift detection results + * @returns {Array} DriftFinding[] */ - async detectDrift(commits, context) { + async detectDrift(commits: any[], context: any) { + const { payload } = context; + const repository = payload.repository; + const owner = repository.owner.login; + const repo = repository.name; + const ref = (payload.ref || "").replace(/^refs\/heads\//, "") || "main"; + const headSha = payload.after || ref; + + const stored = await specStore.listStoredSpecs(context.octokit, { + owner, + repo, + ref: headSha, + }); + + if (stored.length === 0) { + return []; + } + + const changedPaths = new Set(); + for (const commit of commits || []) { + for (const f of commit.added || []) { + changedPaths.add(f); + } + for (const f of commit.modified || []) { + changedPaths.add(f); + } + for (const f of commit.removed || []) { + changedPaths.add(f); + } + } + + // If commit file lists are empty (API often omits them), still check all stored specs + // against current content when we can resolve file content. + const candidates = + changedPaths.size > 0 + ? stored.filter((s: any) => changedPaths.has(s.filePath)) + : stored; + const driftResults = []; - // This is a placeholder implementation - // In a real implementation, this would: - // 1. Compare current implementation with stored specs - // 2. Check if postconditions still hold - // 3. Identify breaking changes + for (const spec of candidates) { + if (changedPaths.size > 0 && changedPaths.has(spec.filePath) === false) { + continue; + } + + // Removed file → drift + if (changedPaths.has(spec.filePath)) { + const removed = (commits || []).some((c: any) => (c.removed || []).includes(spec.filePath)); + if (removed) { + driftResults.push({ + functionName: spec.functionName, + filePath: spec.filePath, + reason: "File removed but accepted spec still exists", + previousSpec: JSON.stringify(spec.contract), + currentImplementation: "(file deleted)", + confidence: 1, + }); + continue; + } + } + + let fileContent = ""; + try { + const response = await context.octokit.repos.getContent({ + owner, + repo, + path: spec.filePath, + ref: headSha, + }); + if (response.data.content) { + fileContent = Buffer.from(response.data.content, "base64").toString("utf8"); + } + } catch (error: unknown) { + const status = (error as { status?: number })?.status; + if (status === 404) { + driftResults.push({ + functionName: spec.functionName, + filePath: spec.filePath, + reason: "Spec target file missing at head", + previousSpec: JSON.stringify(spec.contract), + currentImplementation: "(missing)", + confidence: 1, + }); + continue; + } + console.error(`Drift: failed to read ${spec.filePath}:`, error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error)); + continue; + } - console.log("Drift detection not yet implemented"); + const extracted = this.extractFunctionBody(fileContent, spec.functionName); + if (!extracted) { + driftResults.push({ + functionName: spec.functionName, + filePath: spec.filePath, + reason: "Function no longer found in file (signature removed or renamed)", + previousSpec: JSON.stringify(spec.contract), + currentImplementation: "(not found)", + confidence: 0.9, + }); + continue; + } + + const currentHash = hashImplementation(extracted); + if (spec.implementationHash && spec.implementationHash !== currentHash) { + driftResults.push({ + functionName: spec.functionName, + filePath: spec.filePath, + reason: "Implementation hash differs from accepted snapshot", + previousSpec: `hash:${spec.implementationHash.slice(0, 12)}…`, + currentImplementation: `hash:${currentHash.slice(0, 12)}…`, + confidence: 0.95, + }); + } + } return driftResults; } + /** + * Best-effort extract function body text for hashing. + * @param {string} fileContent + * @param {string} functionName + * @returns {string|null} + */ + extractFunctionBody(fileContent: string, functionName: string) { + const escaped = functionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp( + `(?:async\\s+)?function\\s+${escaped}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`, + "m" + ), + new RegExp( + `(?:const|let|var)\\s+${escaped}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]*?)\\n\\}`, + "m" + ), + new RegExp(`${escaped}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n\\}`, "m"), + new RegExp(`def\\s+${escaped}\\s*\\([^)]*\\)\\s*:([\\s\\S]*?)(?=\\ndef\\s|\\nclass\\s|$)`, "m"), + ]; + + for (const pattern of patterns) { + const match = fileContent.match(pattern); + if (match) { + return match[0]; + } + } + + // Fallback: if name appears, hash surrounding window + const idx = fileContent.indexOf(functionName); + if (idx >= 0) { + return fileContent.slice(idx, Math.min(fileContent.length, idx + 2000)); + } + return null; + } + /** * Handle comment commands * @param {Object} context - GitHub context * @param {Object} comment - Comment object */ - async handleCommentCommand(context, comment) { + async handleCommentCommand(context: any, comment: any) { const { body } = comment; if (body.includes("/specsync accept")) { @@ -179,100 +441,133 @@ export class SpecAnalyzer { } } - /** - * Handle accept command - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object - */ - async handleAcceptCommand(context, comment) { + async handleAcceptCommand(context: any, comment: any) { const { payload } = context; const { repository } = payload; + const issueNumber = + comment.issue_number || payload.issue?.number || payload.pull_request?.number; await context.octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, - issue_number: comment.issue_number, - body: `✅ **SpecSync**: Specification accepted. The spec will be stored and used for future analysis.`, + issue_number: issueNumber, + body: `✅ **SpecSync**: Use \`/specsync accept\` on a SpecSync review comment to persist the spec under \`.specsync/\`.`, }); } - /** - * Handle edit command - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object - */ - async handleEditCommand(context, comment) { + async handleEditCommand(context: any, comment: any) { const { payload } = context; const { repository } = payload; + const issueNumber = + comment.issue_number || payload.issue?.number || payload.pull_request?.number; await context.octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, - issue_number: comment.issue_number, - body: `✏️ **SpecSync**: Please edit the specification in your next comment. Use the format: - + issue_number: issueNumber, + body: `✏️ **SpecSync**: Edit via comment command: + \`\`\` -@spec -preconditions: [list of preconditions] -postconditions: [list of postconditions] -invariants: [list of invariants] -edgeCases: [list of edge cases] +/specsync edit apply +function: +file: +preconditions: +- ... +postconditions: +- ... +invariants: +- ... +reasoning: ... \`\`\``, }); } - /** - * Handle ignore command - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object - */ - async handleIgnoreCommand(context, comment) { + async handleIgnoreCommand(context: any, comment: any) { const { payload } = context; const { repository } = payload; + const issueNumber = + comment.issue_number || payload.issue?.number || payload.pull_request?.number; await context.octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, - issue_number: comment.issue_number, - body: `❌ **SpecSync**: Specification ignored. No further action will be taken.`, + issue_number: issueNumber, + body: `❌ **SpecSync**: Use \`/specsync ignore\` on a SpecSync suggestion comment to record it in \`.specsync/ignores.json\`.`, }); } - /** - * Handle review command - * @param {Object} context - GitHub context - * @param {Object} comment - Comment object - */ - async handleReviewCommand(context, comment) { + async handleReviewCommand(context: any, comment: any) { const { payload } = context; const { repository } = payload; + const issueNumber = + comment.issue_number || payload.issue?.number || payload.pull_request?.number; await context.octokit.issues.createComment({ owner: repository.owner.login, repo: repository.name, - issue_number: comment.issue_number, - body: `🔍 **SpecSync**: Drift analysis completed. Please review the changes and update specifications if needed.`, + issue_number: issueNumber, + body: `🔍 **SpecSync**: Manual review noted. Update or accept the specification when ready.`, }); } /** - * Store spec in persistent storage - * @param {string} functionKey - Function identifier - * @param {Object} spec - Specification object + * Store spec via SpecStore (GitHub Contents API). Prefer SpecStore directly. */ - async storeSpec(functionKey, spec) { - // This would store the spec in a database or file system - console.log(`Storing spec for ${functionKey}:`, spec); + async storeSpec(functionKey: any, spec: any, context: any) { + if (!context?.octokit) { + console.log(`Storing spec for ${functionKey} (no octokit — skipped):`, spec?.functionName); + return null; + } + const { repository, pull_request } = context.payload; + const branch = pull_request?.head?.ref; + if (!branch) { + throw new Error("storeSpec requires pull_request.head.ref"); + } + const stored = { + version: 1, + functionName: spec.functionName, + filePath: spec.filePath, + lineNumber: spec.lineNumber, + acceptedAt: new Date().toISOString(), + pr: pull_request.number, + sha: pull_request.head.sha, + confidence: spec.confidence, + contract: { + preconditions: spec.preconditions || [], + postconditions: spec.postconditions || [], + invariants: spec.invariants || [], + edgeCases: spec.edgeCases || [], + reasoning: spec.reasoning || "", + }, + implementationHash: spec.implementationHash, + }; + return specStore.storeSpec( + context.octokit, + { owner: repository.owner.login, repo: repository.name, ref: branch }, + stored + ); } /** - * Retrieve spec from persistent storage - * @param {string} functionKey - Function identifier - * @returns {Object|null} Stored specification + * Retrieve spec from `.specsync/` store. */ - async retrieveSpec(functionKey) { - // This would retrieve the spec from a database or file system - console.log(`Retrieving spec for ${functionKey}`); - return null; + async retrieveSpec(functionKey: any, context: any, filePath: any, functionName: any) { + if (!context?.octokit) { + console.log(`Retrieving spec for ${functionKey}`); + return null; + } + const { repository, pull_request } = context.payload; + const ref = pull_request?.head?.sha || repository.default_branch || "main"; + const name = functionName || functionKey.split(":").pop(); + const path = filePath || ""; + if (!path || !name) { + return null; + } + return specStore.retrieveSpec( + context.octokit, + { owner: repository.owner.login, repo: repository.name, ref }, + path, + name + ); } } diff --git a/src/spec-coverage.ts b/src/spec-coverage.ts index 71b44cc..6c6507d 100644 --- a/src/spec-coverage.ts +++ b/src/spec-coverage.ts @@ -1,9 +1,12 @@ -// @ts-nocheck const fs = require("fs-extra"); const path = require("path"); -const { exec } = require("child_process"); +const { execFile } = require("child_process"); export class SpecCoverageTracker { + specsDir: any; + sourceDir: any; + coverageThreshold: any; + driftThreshold: any; constructor() { this.specsDir = process.env.SPECS_DIR || "./specs"; this.sourceDir = process.env.SOURCE_DIR || "./src"; @@ -16,8 +19,18 @@ export class SpecCoverageTracker { * @param {Object} context - Analysis context * @returns {Object} Coverage analysis results */ - async analyzeSpecCoverage(context) { - const results = { + async analyzeSpecCoverage(context: any) { + const results: { + totalFunctions: number; + coveredFunctions: number; + uncoveredFunctions: any[]; + coveragePercentage: number; + specFiles: any[]; + userStories: any[]; + failedProofs: any[]; + staleProofs: any[]; + timestamp: string; + } = { totalFunctions: 0, coveredFunctions: 0, uncoveredFunctions: [], @@ -41,7 +54,7 @@ export class SpecCoverageTracker { // Analyze coverage const coverage = await this.calculateCoverage(sourceFiles, specFiles); results.coveredFunctions = coverage.covered; - results.uncoveredFunctions = coverage.uncovered; + results.uncoveredFunctions = coverage.uncoveredFunctions; results.coveragePercentage = coverage.percentage; // Analyze user stories @@ -51,7 +64,7 @@ export class SpecCoverageTracker { const proofAnalysis = await this.analyzeProofStatus(specFiles); results.failedProofs = proofAnalysis.failed; results.staleProofs = proofAnalysis.stale; - } catch (error) { + } catch (error: unknown) { console.error("Error analyzing spec coverage:", error); } @@ -63,10 +76,10 @@ export class SpecCoverageTracker { * @returns {Array} Array of source file paths */ async findSourceFiles() { - const sourceFiles = []; + const sourceFiles: string[] = []; const extensions = [".js", ".ts", ".py", ".java", ".rs", ".cpp", ".c"]; - const walkDir = async (dir) => { + const walkDir = async (dir: string) => { const files = await fs.readdir(dir); for (const file of files) { @@ -99,8 +112,8 @@ export class SpecCoverageTracker { specFiles.push(path.join(this.specsDir, file)); } } - } catch (error) { - console.warn("Specs directory not found:", error.message); + } catch (error: unknown) { + console.warn("Specs directory not found:", (error instanceof Error ? error.message : String(error))); } return specFiles; @@ -111,7 +124,7 @@ export class SpecCoverageTracker { * @param {Array} sourceFiles - Array of source file paths * @returns {number} Total function count */ - async countFunctions(sourceFiles) { + async countFunctions(sourceFiles: any[]) { let totalFunctions = 0; for (const file of sourceFiles) { @@ -119,8 +132,8 @@ export class SpecCoverageTracker { const content = await fs.readFile(file, "utf8"); const functionCount = this.countFunctionsInFile(content, path.extname(file)); totalFunctions += functionCount; - } catch (error) { - console.warn(`Error reading file ${file}:`, error.message); + } catch (error: unknown) { + console.warn(`Error reading file ${file}:`, (error instanceof Error ? error.message : String(error))); } } @@ -133,7 +146,7 @@ export class SpecCoverageTracker { * @param {string} extension - File extension * @returns {number} Function count */ - countFunctionsInFile(content, extension) { + countFunctionsInFile(content: string, extension: string) { let count = 0; switch (extension) { @@ -147,7 +160,7 @@ export class SpecCoverageTracker { /var\s+\w+\s*=\s*\(/g, /=>\s*{/g, ]; - jsPatterns.forEach((pattern) => { + jsPatterns.forEach((pattern: any) => { const matches = content.match(pattern); if (matches) count += matches.length; }); @@ -156,7 +169,7 @@ export class SpecCoverageTracker { case ".py": // Python function patterns const pyPatterns = [/def\s+\w+\s*\(/g]; - pyPatterns.forEach((pattern) => { + pyPatterns.forEach((pattern: any) => { const matches = content.match(pattern); if (matches) count += matches.length; }); @@ -165,7 +178,7 @@ export class SpecCoverageTracker { case ".java": // Java method patterns const javaPatterns = [/(public|private|protected)?\s*(static\s+)?\w+\s+\w+\s*\(/g]; - javaPatterns.forEach((pattern) => { + javaPatterns.forEach((pattern: any) => { const matches = content.match(pattern); if (matches) count += matches.length; }); @@ -174,7 +187,7 @@ export class SpecCoverageTracker { case ".rs": // Rust function patterns const rustPatterns = [/fn\s+\w+\s*\(/g]; - rustPatterns.forEach((pattern) => { + rustPatterns.forEach((pattern: any) => { const matches = content.match(pattern); if (matches) count += matches.length; }); @@ -190,7 +203,7 @@ export class SpecCoverageTracker { * @param {Array} specFiles - Spec file paths * @returns {Object} Coverage results */ - async calculateCoverage(sourceFiles, specFiles) { + async calculateCoverage(sourceFiles: any[], specFiles: any[]) { const covered = []; const uncovered = []; @@ -203,8 +216,8 @@ export class SpecCoverageTracker { if (functionName) { specFunctions.add(functionName); } - } catch (error) { - console.warn(`Error reading spec file ${specFile}:`, error.message); + } catch (error: unknown) { + console.warn(`Error reading spec file ${specFile}:`, (error instanceof Error ? error.message : String(error))); } } @@ -221,8 +234,8 @@ export class SpecCoverageTracker { uncovered.push({ function: func, file: sourceFile }); } } - } catch (error) { - console.warn(`Error analyzing source file ${sourceFile}:`, error.message); + } catch (error: unknown) { + console.warn(`Error analyzing source file ${sourceFile}:`, (error instanceof Error ? error.message : String(error))); } } @@ -243,7 +256,7 @@ export class SpecCoverageTracker { * @param {string} content - Spec file content * @returns {string|null} Function name */ - extractFunctionNameFromSpec(content) { + extractFunctionNameFromSpec(content: string) { const match = content.match(/-- Function: (\w+)/); return match ? match[1] : null; } @@ -254,8 +267,8 @@ export class SpecCoverageTracker { * @param {string} extension - File extension * @returns {Array} Array of function names */ - extractFunctionNames(content, extension) { - const functions = []; + extractFunctionNames(content: string, extension: string) { + const functions: any[] = []; switch (extension) { case ".js": @@ -263,7 +276,7 @@ export class SpecCoverageTracker { // JavaScript/TypeScript function extraction const jsMatches = content.match(/function\s+(\w+)\s*\(/g); if (jsMatches) { - jsMatches.forEach((match) => { + jsMatches.forEach((match: any) => { const name = match.match(/function\s+(\w+)/)[1]; functions.push(name); }); @@ -274,7 +287,7 @@ export class SpecCoverageTracker { // Python function extraction const pyMatches = content.match(/def\s+(\w+)\s*\(/g); if (pyMatches) { - pyMatches.forEach((match) => { + pyMatches.forEach((match: any) => { const name = match.match(/def\s+(\w+)/)[1]; functions.push(name); }); @@ -301,11 +314,11 @@ export class SpecCoverageTracker { const content = await fs.readFile(file, "utf8"); const stories = this.extractUserStories(content); userStories.push(...stories); - } catch (error) { - console.warn(`Error reading user story file ${file}:`, error.message); + } catch (error: unknown) { + console.warn(`Error reading user story file ${file}:`, (error instanceof Error ? error.message : String(error))); } } - } catch (error) { + } catch (error: unknown) { console.warn("No user story files found"); } @@ -324,7 +337,7 @@ export class SpecCoverageTracker { try { const files = await fs.glob(pattern); storyFiles.push(...files); - } catch (error) { + } catch (error: unknown) { // Pattern not found, continue } } @@ -337,7 +350,7 @@ export class SpecCoverageTracker { * @param {string} content - File content * @returns {Array} Array of user stories */ - extractUserStories(content) { + extractUserStories(content: string) { const stories = []; const storyPattern = /(?:As a|User story|Story):\s*(.+?)(?:\n|$)/gi; @@ -358,7 +371,7 @@ export class SpecCoverageTracker { * @param {string} story - User story description * @returns {boolean} Has formal guarantee */ - checkFormalGuarantee(story) { + checkFormalGuarantee(story: string) { const formalKeywords = [ "proof", "theorem", @@ -367,7 +380,7 @@ export class SpecCoverageTracker { "guarantee", "verification", ]; - return formalKeywords.some((keyword) => story.toLowerCase().includes(keyword)); + return formalKeywords.some((keyword: any) => story.toLowerCase().includes(keyword)); } /** @@ -375,7 +388,7 @@ export class SpecCoverageTracker { * @param {Array} specFiles - Array of spec file paths * @returns {Object} Proof analysis results */ - async analyzeProofStatus(specFiles) { + async analyzeProofStatus(specFiles: any[]) { const failed = []; const stale = []; @@ -387,8 +400,8 @@ export class SpecCoverageTracker { } else if (result.status === "stale") { stale.push({ file: specFile, lastModified: result.lastModified }); } - } catch (error) { - console.warn(`Error checking proof status for ${specFile}:`, error.message); + } catch (error: unknown) { + console.warn(`Error checking proof status for ${specFile}:`, (error instanceof Error ? error.message : String(error))); } } @@ -400,7 +413,7 @@ export class SpecCoverageTracker { * @param {string} specFile - Spec file path * @returns {Object} Proof status */ - async checkProofStatus(specFile) { + async checkProofStatus(specFile: string) { try { // Try to compile the Lean4 file const result = await this.compileLeanFile(specFile); @@ -424,10 +437,10 @@ export class SpecCoverageTracker { } return { status: "valid" }; - } catch (error) { + } catch (error: unknown) { return { status: "failed", - error: error.message, + error: (error instanceof Error ? error.message : String(error)), }; } } @@ -437,15 +450,13 @@ export class SpecCoverageTracker { * @param {string} specFile - Spec file path * @returns {Object} Compilation result */ - async compileLeanFile(specFile) { + async compileLeanFile(specFile: string): Promise<{ success: boolean; error?: string; stdout?: string; stderr?: string }> { return new Promise((resolve) => { - const command = `lean --json ${specFile}`; - - exec(command, { timeout: 30000 }, (error, stdout, stderr) => { + execFile("lean", ["--json", specFile], { timeout: 30000 }, (error: Error | null, stdout: string, stderr: string) => { if (error) { resolve({ success: false, - error: error.message, + error: (error instanceof Error ? error.message : String(error)), stderr: stderr, }); } else { @@ -464,7 +475,7 @@ export class SpecCoverageTracker { * @param {Object} context - Analysis context * @returns {Array} Drift detection results */ - async detectDrift(changedFunctions, context) { + async detectDrift(changedFunctions: any[], context: any) { const driftResults = []; for (const func of changedFunctions) { @@ -473,8 +484,8 @@ export class SpecCoverageTracker { if (drift.hasDrift) { driftResults.push(drift); } - } catch (error) { - console.warn(`Error analyzing drift for ${func.functionName}:`, error.message); + } catch (error: unknown) { + console.warn(`Error analyzing drift for ${func.functionName}:`, (error instanceof Error ? error.message : String(error))); } } @@ -487,7 +498,7 @@ export class SpecCoverageTracker { * @param {Object} context - Analysis context * @returns {Object} Drift analysis result */ - async analyzeFunctionDrift(functionData, context) { + async analyzeFunctionDrift(functionData: any, context: any) { const { functionName, functionBody, filePath } = functionData; // Find existing spec for this function @@ -524,7 +535,7 @@ export class SpecCoverageTracker { * @param {string} functionName - Function name * @returns {Object|null} Existing spec */ - async findExistingSpec(functionName) { + async findExistingSpec(functionName: string) { const specFiles = await this.findSpecFiles(); for (const specFile of specFiles) { @@ -533,8 +544,8 @@ export class SpecCoverageTracker { if (content.includes(`Function: ${functionName}`)) { return this.parseSpecFile(content); } - } catch (error) { - console.warn(`Error reading spec file ${specFile}:`, error.message); + } catch (error: unknown) { + console.warn(`Error reading spec file ${specFile}:`, (error instanceof Error ? error.message : String(error))); } } @@ -546,13 +557,13 @@ export class SpecCoverageTracker { * @param {string} content - Spec file content * @returns {Object} Parsed spec */ - parseSpecFile(content) { + parseSpecFile(content: string) { // Extract spec information from Lean4 file const spec = { - preconditions: [], - postconditions: [], - invariants: [], - edgeCases: [], + preconditions: [] as string[], + postconditions: [] as string[], + invariants: [] as string[], + edgeCases: [] as string[], confidence: 0, }; @@ -582,7 +593,7 @@ export class SpecCoverageTracker { * @param {string} predicateString - Predicate string * @returns {Array} Array of predicates */ - parsePredicates(predicateString) { + parsePredicates(predicateString: string) { // Simplified predicate parsing const predicates = []; const parts = predicateString.split("∧"); @@ -602,7 +613,7 @@ export class SpecCoverageTracker { * @param {string} functionBody - Function body * @returns {Object} Implementation analysis */ - async analyzeCurrentImplementation(functionBody) { + async analyzeCurrentImplementation(functionBody: string) { // This would use the same analysis as the spec generator // For now, return a simplified analysis return { @@ -618,7 +629,7 @@ export class SpecCoverageTracker { * @param {string} functionBody - Function body * @returns {number} Complexity score */ - calculateComplexity(functionBody) { + calculateComplexity(functionBody: string) { let complexity = 1; // Count conditionals @@ -642,10 +653,10 @@ export class SpecCoverageTracker { * @param {Object} spec - Existing spec * @returns {Object} Comparison result */ - compareWithSpec(current, spec) { + compareWithSpec(current: any, spec: any) { const drift = { hasDrift: false, - details: [], + details: [] as string[], confidence: 0, }; @@ -673,13 +684,13 @@ export class SpecCoverageTracker { * @param {Object} analysis - Coverage analysis results * @returns {Object} Coverage report */ - generateCoverageReport(analysis) { + generateCoverageReport(analysis: any) { return { summary: { totalFunctions: analysis.totalFunctions, coveredFunctions: analysis.coveredFunctions, coveragePercentage: analysis.coveragePercentage, - userStoriesWithGuarantees: analysis.userStories.filter((s) => s.hasFormalGuarantee).length, + userStoriesWithGuarantees: analysis.userStories.filter((s: any) => s.hasFormalGuarantee).length, totalUserStories: analysis.userStories.length, failedProofs: analysis.failedProofs.length, staleProofs: analysis.staleProofs.length, @@ -700,7 +711,7 @@ export class SpecCoverageTracker { * @param {Object} analysis - Coverage analysis results * @returns {Array} Array of recommendations */ - generateRecommendations(analysis) { + generateRecommendations(analysis: any) { const recommendations = []; if (analysis.coveragePercentage < this.coverageThreshold) { diff --git a/src/spec-store.ts b/src/spec-store.ts new file mode 100644 index 0000000..08e90eb --- /dev/null +++ b/src/spec-store.ts @@ -0,0 +1,399 @@ +/** + * Durable spec store under `.specsync/` in the target repository. + * In-memory cache is single-instance with TTL (not multi-tenant durable). + */ + +import { createHash } from "crypto"; +import type { Confidence, SpecSuggestion } from "./types"; +import { normalizeConfidence } from "./types"; + +export const SPECSYNC_ROOT = ".specsync"; +export const SPECSYNC_SPECS_DIR = `${SPECSYNC_ROOT}/specs`; +export const SPECSYNC_IGNORES_PATH = `${SPECSYNC_ROOT}/ignores.json`; + +const SPECS_DIR = process.env.SPECS_DIR || "specs"; +const CACHE_TTL_MS = Number(process.env.SPECSYNC_CACHE_TTL_MS || 3_600_000); + +export interface SpecContract { + preconditions: string[]; + postconditions: string[]; + invariants: string[]; + edgeCases?: string[]; + reasoning?: string; +} + +export interface StoredSpec { + version: number; + functionName: string; + filePath: string; + lineNumber?: number; + acceptedAt: string; + pr?: number; + sha?: string; + confidence: Confidence; + contract: SpecContract; + /** SHA-256 of implementation body at accept time (for drift). */ + implementationHash?: string; +} + +export interface IgnoreEntry { + functionName: string; + filePath: string; + ignoredAt: string; + pr?: number; + reason?: string; +} + +export interface IgnoresFile { + version: number; + ignores: IgnoreEntry[]; +} + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export interface RepoRef { + owner: string; + repo: string; + /** Branch or commit SHA for reads/writes. */ + ref: string; +} + +type OctokitLike = { + repos: { + getContent: (params: Record) => Promise<{ data: unknown }>; + createOrUpdateFileContents: (params: Record) => Promise<{ data: unknown }>; + }; +}; + +function sanitizePathSegment(value: string): string { + return value.replace(/\\/g, "/").replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); +} + +/** Path: `.specsync/specs/{filePath}__{functionName}.json` */ +export function buildSpecStorePath(filePath: string, functionName: string): string { + const safeFile = sanitizePathSegment(filePath.replace(/\\/g, "/").replace(/\//g, "__")); + const safeFn = sanitizePathSegment(functionName); + return `${SPECSYNC_SPECS_DIR}/${safeFile}__${safeFn}.json`; +} + +/** Lake-known Lean path under SPECS_DIR (default `specs/`). */ +export function buildLeanStorePath(functionName: string): string { + return `${SPECS_DIR}/${sanitizePathSegment(functionName)}_spec.lean`; +} + +export function hashImplementation(body: string): string { + return createHash("sha256").update(body || "").digest("hex"); +} + +export function suggestionToStoredSpec( + suggestion: SpecSuggestion & { implementationHash?: string }, + meta: { pr?: number; sha?: string } +): StoredSpec { + return { + version: 1, + functionName: suggestion.functionName, + filePath: suggestion.filePath, + lineNumber: suggestion.lineNumber, + acceptedAt: new Date().toISOString(), + pr: meta.pr, + sha: meta.sha, + confidence: normalizeConfidence(suggestion.confidence, 0), + contract: { + preconditions: suggestion.preconditions || [], + postconditions: suggestion.postconditions || [], + invariants: suggestion.invariants || [], + edgeCases: suggestion.edgeCases || [], + reasoning: suggestion.reasoning || "", + }, + implementationHash: suggestion.implementationHash, + }; +} + +export class SpecStore { + private cache = new Map>(); + + private cacheKey(owner: string, repo: string, path: string, ref: string): string { + return `${owner}/${repo}@${ref}:${path}`; + } + + private getCached(key: string): T | undefined { + const entry = this.cache.get(key); + if (!entry) { + return undefined; + } + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return undefined; + } + return entry.value as T; + } + + private setCached(key: string, value: T): void { + this.cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + } + + /** Invalidate cache entries for a repo path (any ref). */ + invalidate(owner: string, repo: string, path?: string): void { + const prefix = `${owner}/${repo}`; + for (const key of this.cache.keys()) { + if (!key.startsWith(prefix)) { + continue; + } + if (!path || key.includes(`:${path}`)) { + this.cache.delete(key); + } + } + } + + async retrieveSpec( + octokit: OctokitLike, + repo: RepoRef, + filePath: string, + functionName: string + ): Promise { + const path = buildSpecStorePath(filePath, functionName); + return this.readJsonFile(octokit, repo, path); + } + + async storeSpec( + octokit: OctokitLike, + repo: RepoRef, + stored: StoredSpec, + message?: string + ): Promise<{ path: string; sha?: string }> { + const path = buildSpecStorePath(stored.filePath, stored.functionName); + const content = JSON.stringify(stored, null, 2) + "\n"; + const result = await this.writeFile( + octokit, + repo, + path, + content, + message || `specsync: accept spec for ${stored.functionName}` + ); + this.invalidate(repo.owner, repo.repo, path); + return { path, sha: result.sha }; + } + + async storeLeanFile( + octokit: OctokitLike, + repo: RepoRef, + functionName: string, + leanContent: string, + message?: string + ): Promise<{ path: string }> { + const path = buildLeanStorePath(functionName); + await this.writeFile( + octokit, + repo, + path, + leanContent, + message || `specsync: add Lean stub for ${functionName}` + ); + return { path }; + } + + async listStoredSpecs(octokit: OctokitLike, repo: RepoRef): Promise { + const cacheKey = this.cacheKey(repo.owner, repo.repo, SPECSYNC_SPECS_DIR, repo.ref); + const cached = this.getCached(cacheKey); + if (cached) { + return cached; + } + + try { + const response = await octokit.repos.getContent({ + owner: repo.owner, + repo: repo.repo, + path: SPECSYNC_SPECS_DIR, + ref: repo.ref, + }); + + const data = response.data; + if (!Array.isArray(data)) { + return []; + } + + const specs: StoredSpec[] = []; + for (const entry of data) { + if (entry.type !== "file" || !entry.name?.endsWith(".json") || !entry.path) { + continue; + } + const spec = await this.readJsonFile(octokit, repo, entry.path); + if (spec?.functionName) { + specs.push(spec); + } + } + + this.setCached(cacheKey, specs); + return specs; + } catch (error: unknown) { + const status = (error as { status?: number })?.status; + if (status === 404) { + return []; + } + throw error; + } + } + + async getIgnores(octokit: OctokitLike, repo: RepoRef): Promise { + const file = await this.readJsonFile(octokit, repo, SPECSYNC_IGNORES_PATH); + return file || { version: 1, ignores: [] }; + } + + async addIgnore( + octokit: OctokitLike, + repo: RepoRef, + entry: IgnoreEntry, + message?: string + ): Promise { + const current = await this.getIgnores(octokit, repo); + const withoutDup = current.ignores.filter( + (i) => !(i.functionName === entry.functionName && i.filePath === entry.filePath) + ); + withoutDup.push(entry); + const next: IgnoresFile = { version: 1, ignores: withoutDup }; + await this.writeFile( + octokit, + repo, + SPECSYNC_IGNORES_PATH, + JSON.stringify(next, null, 2) + "\n", + message || `specsync: ignore suggestion for ${entry.functionName}` + ); + this.invalidate(repo.owner, repo.repo, SPECSYNC_IGNORES_PATH); + } + + async isIgnored( + octokit: OctokitLike, + repo: RepoRef, + filePath: string, + functionName: string + ): Promise { + const ignores = await this.getIgnores(octokit, repo); + return ignores.ignores.some((i) => i.functionName === functionName && i.filePath === filePath); + } + + /** + * Coverage = accepted specs covering changed functions / eligible changed functions. + */ + async computeCoverageForFunctions( + octokit: OctokitLike, + repo: RepoRef, + changedFunctions: Array<{ functionName: string; filePath: string; lineNumber?: number }> + ): Promise<{ + coverage: number; + totalFunctions: number; + coveredFunctions: number; + formula: string; + functions: Array<{ + name: string; + filePath: string; + line: number; + hasProof: boolean; + theorem?: string; + }>; + }> { + const eligible = changedFunctions.filter((f) => f.functionName && f.filePath); + const stored = await this.listStoredSpecs(octokit, repo); + const storedKeys = new Set(stored.map((s) => `${s.filePath}::${s.functionName}`)); + + const functions = eligible.map((f) => { + const covered = storedKeys.has(`${f.filePath}::${f.functionName}`); + const match = stored.find( + (s) => s.filePath === f.filePath && s.functionName === f.functionName + ); + return { + name: f.functionName, + filePath: f.filePath, + line: f.lineNumber || 1, + hasProof: covered, + theorem: covered ? `${f.functionName}_spec` : undefined, + }; + }); + + const totalFunctions = functions.length; + const coveredFunctions = functions.filter((f) => f.hasProof).length; + const coverage = + totalFunctions === 0 ? 100 : Math.round((coveredFunctions / totalFunctions) * 100); + const formula = + "coverage = (accepted .specsync specs matching changed functions) / (eligible changed functions) × 100"; + + return { coverage, totalFunctions, coveredFunctions, formula, functions }; + } + + private async readJsonFile( + octokit: OctokitLike, + repo: RepoRef, + path: string + ): Promise { + const cacheKey = this.cacheKey(repo.owner, repo.repo, path, repo.ref); + const cached = this.getCached(cacheKey); + if (cached !== undefined) { + return cached; + } + + try { + const response = await octokit.repos.getContent({ + owner: repo.owner, + repo: repo.repo, + path, + ref: repo.ref, + }); + const data = response.data as { content?: string; encoding?: string }; + if (!data.content) { + return null; + } + const text = Buffer.from(data.content, "base64").toString("utf8"); + const parsed = JSON.parse(text) as T; + this.setCached(cacheKey, parsed); + return parsed; + } catch (error: unknown) { + const status = (error as { status?: number })?.status; + if (status === 404) { + return null; + } + throw error; + } + } + + private async writeFile( + octokit: OctokitLike, + repo: RepoRef, + path: string, + content: string, + message: string + ): Promise<{ sha?: string }> { + let existingSha: string | undefined; + try { + const existing = await octokit.repos.getContent({ + owner: repo.owner, + repo: repo.repo, + path, + ref: repo.ref, + }); + const data = existing.data as { sha?: string }; + existingSha = data.sha; + } catch (error: unknown) { + const status = (error as { status?: number })?.status; + if (status !== 404) { + throw error; + } + } + + const result = await octokit.repos.createOrUpdateFileContents({ + owner: repo.owner, + repo: repo.repo, + path, + message, + content: Buffer.from(content, "utf8").toString("base64"), + branch: repo.ref, + ...(existingSha ? { sha: existingSha } : {}), + }); + + const data = result.data as { content?: { sha?: string } }; + return { sha: data.content?.sha }; + } +} + +export const specStore = new SpecStore(); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a2354af --- /dev/null +++ b/src/types.ts @@ -0,0 +1,154 @@ +/** + * Shared SpecSync contracts. Confidence is always in [0, 1]. + */ + +/** Confidence score in [0, 1] only. Normalize at the LLM parse boundary. */ +export type Confidence = number; + +/** + * GitHub pull-request review comment side. + * LEFT = base/old file (pure deletions); RIGHT = head/new file (adds/modifies). + */ +export type DiffSide = "LEFT" | "RIGHT"; + +export interface SpecSuggestion { + functionName: string; + filePath: string; + lineNumber: number; + /** Review-comment side; defaults to RIGHT when omitted. */ + side?: DiffSide; + preconditions: string[]; + postconditions: string[]; + invariants: string[]; + edgeCases?: string[]; + confidence: Confidence; + reasoning: string; + /** True when the suggestion came from the mock LLM fallback (not a live provider). */ + isMock?: boolean; +} + +export interface ProofFunctionStatus { + name: string; + filePath: string; + line: number; + hasProof: boolean; + theorem?: string; + lastVerified?: string; +} + +export interface ProofResult { + theorem: string; + file: string; + valid: boolean; +} + +export interface ProofStatus { + coverage: number; + totalFunctions: number; + coveredFunctions: number; + failedProofs: number; + functions: ProofFunctionStatus[]; + proofs: ProofResult[]; +} + +export interface DriftFinding { + functionName: string; + filePath: string; + reason: string; + previousSpec?: string; + currentImplementation?: string; + confidence?: Confidence; +} + +/** Payload for the analysis job queue (Phase 2). */ +export interface AnalysisJobPayload { + installationId: number; + owner: string; + repo: string; + pr: number; + headSha: string; +} + +/** Soft LLM cost estimate logged per analysis job. */ +export interface SoftCostEstimate { + estimatedTokens: number; + llmCalls: number; + cappedAt: number; + skippedByBudget?: number; +} + +/** + * Normalize a confidence value to [0, 1]. + * Values greater than 1 are treated as a 0–100 percentage scale. + */ +export function normalizeConfidence(value: unknown, fallback = 0.5): Confidence { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback; + } + + const scaled = value > 1 ? value / 100 : value; + return Math.min(1, Math.max(0, scaled)); +} + +/** Changed file metadata from GitHub pulls.listFiles / paginate. */ +export interface ChangedFile { + filename: string; + status?: string; + additions?: number; + deletions?: number; + changes?: number; + patch?: string; +} + +/** Diff-parser output for a single function change. */ +export interface FunctionChange { + filePath: string; + functionName: string; + functionBody: string; + lineNumber: number; + /** LEFT for pure deletions; RIGHT otherwise. */ + side: DiffSide; + changeType: string; + comments: Array<{ text: string; lineNumber: number; type: string }>; + testFiles: string[]; +} + +/** Extracted function region inside a unified diff. */ +export interface DiffFunction { + name: string; + body: string; + lineNumber: number; + /** LEFT when the function only exists on the base side (deletion). */ + side: DiffSide; + changeType: string; +} + +/** Context passed into the LLM for a single function. */ +export interface LLMFunctionContext { + functionName?: string; + language?: string; + functionBody?: string; + inputTypes?: Array<{ name?: string; type?: string; required?: boolean }>; + outputType?: string; + conditionalGuards?: Array<{ condition?: string }>; + loops?: unknown[]; + earlyReturns?: Array<{ value?: string }>; + controlFlow?: string; + complexity?: number; + comments?: string; +} + +/** Parsed / mock LLM specification payload (before SpecSuggestion enrichment). */ +export interface GeneratedSpecPayload { + preconditions: string[]; + postconditions: string[]; + invariants: string[]; + edgeCases: string[]; + complexity?: { time?: string; space?: string }; + security?: { vulnerabilities?: string[]; mitigations?: string[] }; + confidence: Confidence; + reasoning: string; + isMock?: boolean; + unavailable?: boolean; + reason?: string; +} diff --git a/src/ui-demo.ts b/src/ui-demo.ts index 1405e94..8a0d41b 100644 --- a/src/ui-demo.ts +++ b/src/ui-demo.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node -// @ts-nocheck /** * SpecSync UI Framework Demo @@ -9,10 +8,10 @@ */ import { GitHubUI } from "./github-ui"; -import { VSCodeUI } from "./vscode-ui"; import { WebDashboard } from "./web-dashboard"; import { SlackAlerts } from "./slack-alerts"; import { UIPrinciples } from "./ui-principles"; +import * as path from "path"; async function runUIDemo() { console.log("🎨 SpecSync UI Framework Demo\n"); @@ -20,7 +19,6 @@ async function runUIDemo() { // Initialize all UI components const githubUI = new GitHubUI(); - const vscodeUI = new VSCodeUI(); const webDashboard = new WebDashboard(); const slackAlerts = new SlackAlerts(); const uiPrinciples = new UIPrinciples(); @@ -63,7 +61,7 @@ async function runUIDemo() { // Demo each component await demoGitHubUI(githubUI); - await demoVSCodeUI(vscodeUI); + await demoVSCodeUI(); await demoWebDashboard(webDashboard); await demoSlackAlerts(slackAlerts); await demoUIPrinciples(uiPrinciples); @@ -77,7 +75,7 @@ async function runUIDemo() { console.log(" • Comprehensive error handling and validation"); } -async function demoGitHubUI(githubUI) { +async function demoGitHubUI(githubUI: any) { console.log("\n🔗 Demo: GitHub PR UI Components"); console.log("====================================="); @@ -95,19 +93,19 @@ async function demoGitHubUI(githubUI) { }, octokit: { pulls: { - createReviewComment: async (params) => { + createReviewComment: async (params: any) => { console.log(` 📝 Created review comment for ${params.path}:${params.line}`); return { id: 1 }; }, }, checks: { - create: async (params) => { + create: async (params: any) => { console.log(` ✅ Created coverage check: ${params.output.title}`); return { id: 1 }; }, }, issues: { - createComment: async (params) => { + createComment: async (params: any) => { console.log(` 💬 Created proof check comment`); return { id: 1 }; }, @@ -184,7 +182,7 @@ async function demoGitHubUI(githubUI) { console.log(` Actions: Prove Now | View Dashboard | Export Report`); } -async function demoVSCodeUI(vscodeUI) { +async function demoVSCodeUI() { console.log("\n💻 Demo: VSCode Plugin Components"); console.log("===================================="); @@ -209,12 +207,12 @@ async function demoVSCodeUI(vscodeUI) { console.log(" • Success/fail notifications"); console.log(" • Artifact generation and storage"); - // Generate VSCode extension - const extensionPath = await vscodeUI.generateVSCodeExtension(); - console.log(`\n 📦 Generated VSCode extension at: ${extensionPath}`); + // Hand-maintained extension (src/vscode-ui.ts generator removed in Phase 8) + const extensionPath = path.resolve("./vscode-extension"); + console.log(`\n 📦 VS Code extension (hand-maintained): ${extensionPath}`); } -async function demoWebDashboard(webDashboard) { +async function demoWebDashboard(webDashboard: any) { console.log("\n🌐 Demo: Web Dashboard Components"); console.log("===================================="); @@ -246,13 +244,15 @@ async function demoWebDashboard(webDashboard) { console.log(" • Download proof artifacts"); console.log(" • Compliance-friendly metadata"); - // Start dashboard - console.log("\n 🚀 Starting dashboard server..."); - // webDashboard.start(); // Uncomment to actually start server - console.log(" Dashboard would be available at: http://localhost:3001"); + // Start dashboard only when explicitly enabled (demo leaves it off) + console.log("\n 🚀 Dashboard start gate..."); + console.log(" Set SPECSYNC_DASHBOARD=1 and SPECSYNC_DASHBOARD_SECRET to listen"); + console.log(` Current: SPECSYNC_DASHBOARD=${process.env.SPECSYNC_DASHBOARD || "0"}`); + webDashboard.start(); + console.log(" (start() is a no-op without flag + secret)"); } -async function demoSlackAlerts(slackAlerts) { +async function demoSlackAlerts(slackAlerts: any) { console.log("\n💬 Demo: Slack Alert Components"); console.log("=================================="); @@ -299,7 +299,7 @@ async function demoSlackAlerts(slackAlerts) { ); } -async function demoUIPrinciples(uiPrinciples) { +async function demoUIPrinciples(uiPrinciples: any) { console.log("\n🎯 Demo: UI Design Principles"); console.log("================================"); diff --git a/src/ui-principles.ts b/src/ui-principles.ts index 89edc6b..f043579 100644 --- a/src/ui-principles.ts +++ b/src/ui-principles.ts @@ -1,8 +1,9 @@ -// @ts-nocheck const fs = require("fs-extra"); const path = require("path"); export class UIPrinciples { + experimentalFlag: any; + confidenceThreshold: any; constructor() { this.experimentalFlag = process.env.SPECSYNC_EXPERIMENTAL || "suggest-only"; this.confidenceThreshold = 0.7; // Minimum confidence for displaying suggestions @@ -13,13 +14,13 @@ export class UIPrinciples { * "For each new UI element, provide screenshot GIFs showing default-collapsed state and expanded state. * Reviewer must confirm collapsed state occupies ≤ 24 px height, zero flicker." */ - validateInvisibleUntilHelpful(element) { + validateInvisibleUntilHelpful(element: any) { const validation = { element: element.name, collapsedHeight: this.measureCollapsedHeight(element), hasFlicker: this.detectFlicker(element), isValid: true, - issues: [], + issues: [] as string[], screenshotRequirements: this.generateScreenshotRequirements(element), }; @@ -43,7 +44,7 @@ export class UIPrinciples { * @param {Object} element - UI element object * @returns {number} Height in pixels */ - measureCollapsedHeight(element) { + measureCollapsedHeight(element: any) { // Enhanced height measurements based on element type and content const heightMap = { "spec-comment": 20, @@ -57,7 +58,7 @@ export class UIPrinciples { }; // Adjust height based on content length - let baseHeight = heightMap[element.type] || 20; + let baseHeight = (heightMap as Record)[element.type] || 20; if (element.content && element.content.length > 50) { baseHeight += 4; // Add padding for longer content @@ -71,7 +72,7 @@ export class UIPrinciples { * @param {Object} element - UI element object * @returns {boolean} Has flicker */ - detectFlicker(element) { + detectFlicker(element: any) { // Enhanced flicker detection patterns const flickerPatterns = [ "opacity: 0", @@ -107,7 +108,7 @@ export class UIPrinciples { * @param {Object} element - UI element object * @returns {Object} Screenshot requirements */ - generateScreenshotRequirements(element) { + generateScreenshotRequirements(element: any) { return { element: element.name, requirements: [ @@ -183,7 +184,7 @@ export class UIPrinciples { * @param {Object} suggestion - Suggestion object * @returns {boolean} Has confidence field */ - hasConfidenceField(suggestion) { + hasConfidenceField(suggestion: any) { return suggestion.hasOwnProperty("confidence") && typeof suggestion.confidence === "number"; } @@ -192,12 +193,12 @@ export class UIPrinciples { * @param {Object} suggestion - Suggestion object * @returns {boolean} Has rationale field */ - hasRationaleField(suggestion) { - return ( - suggestion.hasOwnProperty("rationale") && - typeof suggestion.rationale === "string" && - suggestion.rationale.length > 0 - ); + hasRationaleField(suggestion: any) { + const text = + (typeof suggestion.rationale === "string" && suggestion.rationale) || + (typeof suggestion.reasoning === "string" && suggestion.reasoning) || + ""; + return text.length > 0; } /** @@ -205,17 +206,17 @@ export class UIPrinciples { * @param {Object} suggestion - Suggestion object * @returns {number} Confidence value */ - getConfidenceValue(suggestion) { + getConfidenceValue(suggestion: any) { return suggestion.confidence || 0; } /** - * Get rationale text from suggestion + * Get rationale text from suggestion (`rationale` or canonical `reasoning`) * @param {Object} suggestion - Suggestion object * @returns {string} Rationale text */ - getRationaleText(suggestion) { - return suggestion.rationale || ""; + getRationaleText(suggestion: any) { + return suggestion.rationale || suggestion.reasoning || ""; } /** @@ -231,7 +232,7 @@ export class UIPrinciples { * @param {Object} feature - Feature object * @returns {boolean} Requires experimental flag */ - requiresExperimentalFlag(feature) { + requiresExperimentalFlag(feature: any) { const experimentalFeatures = [ "proof-gating", "drift-detection", @@ -248,7 +249,7 @@ export class UIPrinciples { * @param {Object} feature - Feature object * @returns {boolean} Is enabled */ - isFeatureEnabled(feature) { + isFeatureEnabled(feature: any) { if (!this.requiresExperimentalFlag(feature)) { return true; // Non-experimental features are always enabled } @@ -261,7 +262,7 @@ export class UIPrinciples { * "Every LLM suggestion must include confidence (0-1) and rationale fields in payload. * Frontend renders both; failing to display them is CI-blocking via Jest snapshot test." */ - validateConfidenceTransparency(suggestion) { + validateConfidenceTransparency(suggestion: any) { const validation = { suggestion: suggestion.functionName, hasConfidence: this.hasConfidenceField(suggestion), @@ -269,7 +270,7 @@ export class UIPrinciples { confidenceValue: this.getConfidenceValue(suggestion), rationaleText: this.getRationaleText(suggestion), isValid: true, - issues: [], + issues: [] as string[], frontendRendering: this.validateFrontendRendering(suggestion), }; @@ -316,10 +317,10 @@ export class UIPrinciples { * @param {Object} suggestion - Suggestion object * @returns {Object} Frontend rendering validation */ - validateFrontendRendering(suggestion) { + validateFrontendRendering(suggestion: any) { const validation = { isValid: true, - issues: [], + issues: [] as string[], components: { confidenceDisplay: false, rationaleDisplay: false, @@ -336,8 +337,9 @@ export class UIPrinciples { validation.issues.push("Confidence not displayed in frontend"); } - // Check if rationale is displayed - if (suggestion.rationale && suggestion.rationale.length > 0) { + // Check if rationale is displayed (canonical field is `reasoning`) + const rationaleText = suggestion.rationale || suggestion.reasoning || ""; + if (rationaleText.length > 0) { validation.components.rationaleDisplay = true; } else { validation.isValid = false; @@ -353,7 +355,7 @@ export class UIPrinciples { } // Check accessibility features - if (suggestion.confidence !== undefined && suggestion.rationale) { + if (suggestion.confidence !== undefined && rationaleText) { validation.components.accessibility = true; } else { validation.issues.push("Accessibility features missing for confidence/rationale display"); @@ -367,7 +369,7 @@ export class UIPrinciples { * @param {Object} suggestion - Suggestion object * @returns {string} Jest test code */ - generateConfidenceSnapshotTest(suggestion) { + generateConfidenceSnapshotTest(suggestion: any) { return ` describe('Confidence Transparency', () => { test('should display confidence and rationale for ${suggestion.functionName}', () => { @@ -424,7 +426,7 @@ describe('Confidence Transparency', () => { * @param {Object} suggestion - Suggestion object * @returns {string} React component code */ - generateConfidenceDisplayComponent(suggestion) { + generateConfidenceDisplayComponent(suggestion: any) { return ` import React from 'react'; @@ -480,7 +482,7 @@ export const SuggestionDisplay: React.FC = ({

Preconditions

    - {preconditions.map((pre, index) => ( + {preconditions.map((pre: any, index: any) => (
  • {pre}
  • ))}
@@ -491,7 +493,7 @@ export const SuggestionDisplay: React.FC = ({

Postconditions

    - {postconditions.map((post, index) => ( + {postconditions.map((post: any, index: any) => (
  • {post}
  • ))}
@@ -502,7 +504,7 @@ export const SuggestionDisplay: React.FC = ({

Invariants

    - {invariants.map((inv, index) => ( + {invariants.map((inv: any, index: any) => (
  • {inv}
  • ))}
@@ -520,14 +522,14 @@ export const SuggestionDisplay: React.FC = ({ * "All critical features gated behind specsync.experimental flag. * Default = 'suggest-only'. Proof gating enabled per-repo via Settings toggle." */ - validateProgressiveEnhancement(feature) { + validateProgressiveEnhancement(feature: any) { const validation = { feature: feature.name, experimentalFlag: this.experimentalFlag, isEnabled: this.isFeatureEnabled(feature), requiresExperimental: this.requiresExperimentalFlag(feature), isValid: true, - issues: [], + issues: [] as string[], gatingConfiguration: this.generateGatingConfiguration(feature), }; @@ -559,10 +561,10 @@ export const SuggestionDisplay: React.FC = ({ * @param {Object} feature - Feature object * @returns {Object} Gating configuration */ - generateGatingConfiguration(feature) { + generateGatingConfiguration(feature: any) { const config = { isValid: true, - issues: [], + issues: [] as string[], settings: { experimental: this.experimentalFlag, perRepo: feature.perRepo || false, @@ -745,7 +747,7 @@ export const SuggestionDisplay: React.FC = ({
- - -\`; -} - -export function deactivate() {} -`; - } - - /** - * Prompt 2.2 — Spec Suggestion Panel - * Goal: Real-time side panel surfacing speculative invariants as developer types. - */ - generateSuggestionPanel() { - return { - name: "specsync-suggestion-panel", - displayName: "SpecSync Suggestion Panel", - description: "Real-time spec suggestions as you type", - version: "1.0.0", - publisher: "specsync", - categories: ["Programming Languages", "Other"], - activationEvents: ["onLanguage:javascript", "onLanguage:typescript"], - main: "./out/suggestion-panel.js", - contributes: { - viewsContainers: { - activitybar: [ - { - id: "specsync-suggestions", - title: "SpecSync Suggestions", - icon: "resources/specsync-icon.svg", - }, - ], - }, - views: { - "specsync-suggestions": [ - { - id: "specsync-suggestion-list", - name: "Suggestions", - when: "true", - }, - ], - }, - commands: [ - { - command: "specsync.addSuggestion", - title: "Add Suggestion", - category: "SpecSync", - }, - ], - }, - }; - } - - /** - * Generate suggestion panel TypeScript implementation - * @returns {string} TypeScript code - */ - generateSuggestionPanelTypeScript() { - return `import * as vscode from 'vscode'; - -export function activate(context: vscode.ExtensionContext) { - const suggestionProvider = new SpecSuggestionProvider(); - - context.subscriptions.push( - vscode.window.registerTreeDataProvider('specsync-suggestion-list', suggestionProvider), - vscode.commands.registerCommand('specsync.addSuggestion', addSuggestion) - ); - - // Listen for text changes - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument(handleTextChange) - ); -} - -class SpecSuggestionProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - - private suggestions: SpecSuggestion[] = []; - - getTreeItem(element: SpecSuggestion): vscode.TreeItem { - return element; - } - - getChildren(element?: SpecSuggestion): Thenable { - if (element) { - return Promise.resolve([]); - } - return Promise.resolve(this.suggestions); - } - - updateSuggestions(newSuggestions: SpecSuggestion[]) { - this.suggestions = newSuggestions; - this._onDidChangeTreeData.fire(undefined); - } -} - -class SpecSuggestion extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly confidence: number, - public readonly reasoning: string, - public readonly type: 'precondition' | 'postcondition' | 'invariant' - ) { - super(label, vscode.TreeItemCollapsibleState.None); - - this.tooltip = \`Confidence: \${confidence}%\\nReasoning: \${reasoning}\`; - this.description = \`\${confidence}% confidence\`; - this.contextValue = 'specSuggestion'; - - this.command = { - command: 'specsync.addSuggestion', - title: 'Add Suggestion', - arguments: [this] - }; - } - - iconPath = new vscode.ThemeIcon('lightbulb'); -} - -async function handleTextChange(event: vscode.TextDocumentChangeEvent) { - const document = event.document; - const changes = event.contentChanges; - - for (const change of changes) { - const text = change.text; - const position = change.range.start; - - // Analyze the change for potential spec suggestions - const suggestions = await analyzeTextChange(text, position, document); - - if (suggestions.length > 0) { - // Update the suggestion panel - const provider = vscode.workspace.getConfiguration('specsync').get('suggestionProvider'); - if (provider) { - provider.updateSuggestions(suggestions); - } - } - } -} - -async function analyzeTextChange(text: string, position: vscode.Position, document: vscode.TextDocument): Promise { - const suggestions: SpecSuggestion[] = []; - - // Simple pattern matching for common cases - if (text.includes('balance -= amount')) { - suggestions.push(new SpecSuggestion( - 'Ensure balance ≥ 0', - 85, - 'Detected balance reduction - should maintain non-negative balance', - 'invariant' - )); - } - - if (text.includes('return result')) { - suggestions.push(new SpecSuggestion( - 'Result is not null', - 90, - 'Function returns a value - should ensure it\'s not null', - 'postcondition' - )); - } - - if (text.includes('if (input')) { - suggestions.push(new SpecSuggestion( - 'Input is validated', - 80, - 'Detected input validation - should document precondition', - 'precondition' - )); - } - - return suggestions; -} - -async function addSuggestion(suggestion: SpecSuggestion) { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showErrorMessage('No active editor'); - return; - } - - // Find the function at current position - const position = editor.selection.active; - const functionName = getFunctionAtPosition(editor.document, position); - - if (!functionName) { - vscode.window.showErrorMessage('No function found at current position'); - return; - } - - // Add the suggestion to the function's spec - await addSuggestionToFunction(functionName, suggestion); - - vscode.window.showInformationMessage(\`Added \${suggestion.type}: \${suggestion.label}\`); -} - -function getFunctionAtPosition(document: vscode.TextDocument, position: vscode.Position): string | null { - const line = document.lineAt(position.line); - const functionMatch = line.text.match(/function\\s+(\\w+)\\s*\\(/); - return functionMatch ? functionMatch[1] : null; -} - -async function addSuggestionToFunction(functionName: string, suggestion: SpecSuggestion) { - // This would integrate with the spec storage system - const config = vscode.workspace.getConfiguration('specsync'); - const cachePath = config.get('specCachePath', '.specsync/spec.json'); - - try { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) return; - - const fullCachePath = path.join(workspaceFolder.uri.fsPath, cachePath); - let cache = {}; - - if (fs.existsSync(fullCachePath)) { - const cacheContent = fs.readFileSync(fullCachePath, 'utf8'); - cache = JSON.parse(cacheContent); - } - - const key = \`\${workspaceFolder.name}:\${functionName}\`; - if (!cache[key]) { - cache[key] = { - functionName, - preconditions: [], - postconditions: [], - invariants: [], - confidence: 0, - reasoning: '' - }; - } - - // Add the suggestion to the appropriate array - switch (suggestion.type) { - case 'precondition': - cache[key].preconditions.push(suggestion.label); - break; - case 'postcondition': - cache[key].postconditions.push(suggestion.label); - break; - case 'invariant': - cache[key].invariants.push(suggestion.label); - break; - } - - // Ensure directory exists - const dir = path.dirname(fullCachePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync(fullCachePath, JSON.stringify(cache, null, 2)); - - } catch (error) { - console.error('Error saving suggestion:', error); - vscode.window.showErrorMessage(\`Failed to save suggestion: \${error.message}\`); - } -} - -export function deactivate() {} -`; - } - - /** - * Prompt 2.3 — Lean Proof Task Runner - * Goal: One-click local proof execution with progress UI. - */ - generateProofRunner() { - return { - name: "specsync-proof-runner", - displayName: "SpecSync Proof Runner", - description: "Local Lean proof execution with progress UI", - version: "1.0.0", - publisher: "specsync", - categories: ["Programming Languages", "Other"], - activationEvents: ["onCommand:specsync.runProof"], - main: "./out/proof-runner.js", - contributes: { - commands: [ - { - command: "specsync.runProof", - title: "Run Lean Proof", - category: "SpecSync", - }, - { - command: "specsync.cancelProof", - title: "Cancel Proof", - category: "SpecSync", - }, - ], - menus: { - commandPalette: [ - { - command: "specsync.runProof", - when: "workspaceHasSpecs", - }, - ], - }, - }, - }; - } - - /** - * Generate proof runner TypeScript implementation - * @returns {string} TypeScript code - */ - generateProofRunnerTypeScript() { - return `import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as path from 'path'; -import { exec, spawn } from 'child_process'; - -export function activate(context: vscode.ExtensionContext) { - let currentProofProcess: any = null; - - context.subscriptions.push( - vscode.commands.registerCommand('specsync.runProof', () => runProof(currentProofProcess)), - vscode.commands.registerCommand('specsync.cancelProof', () => cancelProof(currentProofProcess)) - ); -} - -async function runProof(currentProcess: any) { - const config = vscode.workspace.getConfiguration('specsync'); - const leanPath = config.get('leanPath', 'lean'); - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showErrorMessage('No workspace folder found'); - return; - } - - const specsDir = path.join(workspaceFolder.uri.fsPath, 'specs'); - if (!fs.existsSync(specsDir)) { - vscode.window.showErrorMessage('No specs directory found'); - return; - } - - const leanFiles = fs.readdirSync(specsDir).filter(f => f.endsWith('.lean')); - if (leanFiles.length === 0) { - vscode.window.showInformationMessage('No Lean files found in specs directory'); - return; - } - - const progressOptions = { - location: vscode.ProgressLocation.Notification, - title: 'Running Lean Proofs', - cancellable: true - }; - - const outputChannel = vscode.window.createOutputChannel('SpecSync Proof Runner'); - outputChannel.show(); - - await vscode.window.withProgress(progressOptions, async (progress, token) => { - try { - progress.report({ message: 'Initializing Lean environment...' }); - - let successCount = 0; - let totalCount = leanFiles.length; - const results: any[] = []; - - for (const file of leanFiles) { - if (token.isCancellationRequested) { - outputChannel.appendLine('Proof execution cancelled by user'); - break; - } - - const filePath = path.join(specsDir, file); - progress.report({ - message: \`Processing \${file}...\`, - increment: 100 / totalCount - }); - - outputChannel.appendLine(\`\\nProcessing \${file}...\`); - - try { - const result = await executeLeanFile(leanPath, filePath, outputChannel); - results.push({ file, success: result.success, output: result.output }); - - if (result.success) { - successCount++; - outputChannel.appendLine(\`✅ \${file} - Proof successful\`); - } else { - outputChannel.appendLine(\`❌ \${file} - Proof failed\`); - } - } catch (error) { - results.push({ file, success: false, error: error.message }); - outputChannel.appendLine(\`❌ \${file} - Error: \${error.message}\`); - } - } - - const coverage = Math.round((successCount / totalCount) * 100); - - // Show results summary - const summary = \` -## Proof Execution Summary - -**Coverage:** \${coverage}% -**Successful:** \${successCount}/\${totalCount} -**Failed:** \${totalCount - successCount} - -### Results: -\${results.map(r => \`\${r.success ? '✅' : '❌'} \${r.file}\`).join('\\n')} -\`; - - outputChannel.appendLine(summary); - - // Show notification - if (coverage >= 70) { - vscode.window.showInformationMessage( - \`Proof validation complete: \${successCount}/\${totalCount} successful (\${coverage}% coverage)\` - ); - } else { - vscode.window.showWarningMessage( - \`Proof validation complete: \${successCount}/\${totalCount} successful (\${coverage}% coverage) - Below threshold\` - ); - } - - // Generate artifacts - await generateProofArtifacts(results, workspaceFolder.uri.fsPath); - - } catch (error) { - outputChannel.appendLine(\`\\n❌ Proof execution failed: \${error.message}\`); - vscode.window.showErrorMessage(\`Proof execution failed: \${error.message}\`); - } - }); -} - -function executeLeanFile(leanPath: string, filePath: string, outputChannel: vscode.OutputChannel): Promise<{success: boolean, output: string}> { - return new Promise((resolve, reject) => { - const process = spawn(leanPath, ['--json', filePath], { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - let stdout = ''; - let stderr = ''; - - process.stdout.on('data', (data) => { - stdout += data.toString(); - outputChannel.append(data.toString()); - }); - - process.stderr.on('data', (data) => { - stderr += data.toString(); - outputChannel.append(data.toString()); - }); - - process.on('close', (code) => { - if (code === 0) { - resolve({ success: true, output: stdout }); - } else { - resolve({ success: false, output: stderr }); - } - }); - - process.on('error', (error) => { - reject(error); - }); - }); -} - -async function generateProofArtifacts(results: any[], workspacePath: string) { - const artifactsDir = path.join(workspacePath, '.specsync', 'artifacts'); - - // Ensure artifacts directory exists - if (!fs.existsSync(artifactsDir)) { - fs.mkdirSync(artifactsDir, { recursive: true }); - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const reportPath = path.join(artifactsDir, \`proof-report-\${timestamp}.json\`); - - const report = { - timestamp: new Date().toISOString(), - totalFiles: results.length, - successfulProofs: results.filter(r => r.success).length, - failedProofs: results.filter(r => !r.success).length, - coverage: Math.round((results.filter(r => r.success).length / results.length) * 100), - results: results - }; - - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - - // Create a summary file - const summaryPath = path.join(artifactsDir, 'latest-report.json'); - fs.writeFileSync(summaryPath, JSON.stringify(report, null, 2)); - - vscode.window.showInformationMessage(\`Proof artifacts saved to \${artifactsDir}\`); -} - -function cancelProof(currentProcess: any) { - if (currentProcess) { - currentProcess.kill(); - vscode.window.showInformationMessage('Proof execution cancelled'); - } -} - -export function deactivate() {} -`; - } - - /** - * Generate complete VSCode extension package - * @param {string} outputPath - Output directory - */ - async generateVSCodeExtension(outputPath = "./vscode-extension") { - await fs.ensureDir(outputPath); - - // Generate package.json - const packageJson = this.generateSpecLensExtension(); - await fs.writeJson(path.join(outputPath, "package.json"), packageJson, { spaces: 2 }); - - // Generate TypeScript files - const srcDir = path.join(outputPath, "src"); - await fs.ensureDir(srcDir); - - await fs.writeFile(path.join(srcDir, "extension.ts"), this.generateSpecLensTypeScript()); - - await fs.writeFile( - path.join(srcDir, "suggestion-panel.ts"), - this.generateSuggestionPanelTypeScript() - ); - - await fs.writeFile(path.join(srcDir, "proof-runner.ts"), this.generateProofRunnerTypeScript()); - - // Generate README - const readme = this.generateVSCodeReadme(); - await fs.writeFile(path.join(outputPath, "README.md"), readme); - - // Generate tsconfig.json - const tsconfig = { - compilerOptions: { - module: "commonjs", - target: "ES2020", - outDir: "out", - lib: ["ES2020"], - sourceMap: true, - rootDir: "src", - strict: true, - }, - exclude: ["node_modules", ".vscode-test"], - }; - await fs.writeJson(path.join(outputPath, "tsconfig.json"), tsconfig, { spaces: 2 }); - - return outputPath; - } - - /** - * Generate VSCode extension README - * @returns {string} README content - */ - generateVSCodeReadme() { - return `# SpecSync VSCode Extension - -A comprehensive VSCode extension for SpecSync that provides inline specification annotations, real-time suggestions, and local proof execution. - -## Features - -### SpecLens Inline Annotations -- Display verified invariants above each function -- Click to view full specification details -- Edit specifications inline - -### Real-time Suggestion Panel -- Get spec suggestions as you type -- Add suggestions with one click -- Confidence scoring for each suggestion - -### Lean Proof Runner -- Execute Lean proofs locally -- Progress tracking with detailed output -- Generate proof artifacts and reports - -## Installation - -1. Install the extension from the VSCode marketplace -2. Configure your Lean path in settings -3. Open a project with specifications - -## Usage - -### Viewing Specifications -- Hover over functions to see their specifications -- Click on spec annotations to view details -- Use the command palette: "SpecSync: Show Specification" - -### Adding Suggestions -- Type code and watch for suggestions in the sidebar -- Click on suggestions to add them to your function -- Suggestions appear based on code patterns - -### Running Proofs -- Use command palette: "SpecSync: Run Lean Proof" -- View progress and results in the output panel -- Proof artifacts are saved to \`.specsync/artifacts\` - -## Configuration - -\`\`\`json -{ - "specsync.specCachePath": ".specsync/spec.json", - "specsync.leanPath": "lean" -} -\`\`\` - -## Development - -\`\`\`bash -npm install -npm run compile -npm run watch -\`\`\` - -## License - -MIT License -`; - } -} diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index ddc30f8..978b09b 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -1,897 +1,410 @@ -// @ts-nocheck +/** + * SpecSync local dashboard — serves data from `.specsync/` only. + * Started only when SPECSYNC_DASHBOARD=1 and SPECSYNC_DASHBOARD_SECRET is set. + * All HTTP routes (including static) require the shared-secret header. + */ const express = require("express"); const path = require("path"); -const fs = require("fs-extra"); +const fsExtra = require("fs-extra"); +const fs = fsExtra.default ?? fsExtra; +const { analysisJobQueue } = require("./analysis-queue"); + +const SPECSYNC_ROOT = ".specsync"; + +function escapeHtml(value: any) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const EMPTY_COVERAGE = { + summary: { + totalFunctions: 0, + coveredFunctions: 0, + coveragePercentage: 0, + lastUpdated: null, + }, + modules: [], + source: "empty", +}; + +const EMPTY_DRIFT = { + timeline: [], + summary: { + totalDrifts: 0, + highSeverity: 0, + mediumSeverity: 0, + lowSeverity: 0, + resolved: 0, + pending: 0, + }, + source: "empty", +}; + +const EMPTY_STORIES = { stories: [], source: "empty" }; export class WebDashboard { + app: any; + port: any; + host: any; + secret: any; + server: any; constructor() { this.app = express(); - this.port = process.env.DASHBOARD_PORT || 3001; + this.port = Number(process.env.DASHBOARD_PORT || 3001); + this.host = process.env.DASHBOARD_HOST || "127.0.0.1"; + this.secret = process.env.SPECSYNC_DASHBOARD_SECRET || ""; + this.server = null; this.setupMiddleware(); this.setupRoutes(); } - /** - * Setup middleware - */ setupMiddleware() { - this.app.use(express.json()); - this.app.use(express.static(path.join(__dirname, "public"))); - this.app.set("view engine", "ejs"); - this.app.set("views", path.join(__dirname, "views")); + this.app.use(express.json({ limit: "256kb" })); + this.app.use(this.requireDashboardSecret.bind(this)); } /** - * Setup routes + * Shared-secret AuthZ before any HTTP API or HTML page. + * Accepts `X-SpecSync-Dashboard-Secret` or `Authorization: Bearer `. */ + requireDashboardSecret(req: any, res: any, next: any) { + if (!this.secret) { + res.status(503).json({ + error: "Dashboard secret not configured (SPECSYNC_DASHBOARD_SECRET)", + }); + return; + } + + const headerSecret = req.get("x-specsync-dashboard-secret"); + const auth = req.get("authorization") || ""; + const bearer = auth.toLowerCase().startsWith("bearer ") + ? auth.slice(7).trim() + : ""; + const provided = headerSecret || bearer; + + if (!provided || provided !== this.secret) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); + } + setupRoutes() { - // Prompt 3.1 — Spec Coverage Map this.app.get("/api/coverage", this.getCoverageData.bind(this)); - this.app.get("/coverage", this.renderCoverageMap.bind(this)); - - // Prompt 3.2 — Spec Drift Monitor this.app.get("/api/drift", this.getDriftData.bind(this)); - this.app.get("/drift", this.renderDriftMonitor.bind(this)); - - // Prompt 3.3 — Story ↔ Spec Linker this.app.get("/api/stories", this.getStoriesData.bind(this)); - this.app.get("/stories", this.renderStoryLinker.bind(this)); - - // Prompt 3.4 — Audit Explorer this.app.get("/api/audit/:functionId", this.getAuditData.bind(this)); - this.app.get("/audit/:functionId", this.renderAuditExplorer.bind(this)); + this.app.get("/api/jobs", this.getJobsMetrics.bind(this)); + this.app.get("/api/specs", this.listStoredSpecs.bind(this)); - // Dashboard home + this.app.get("/coverage", this.renderCoverageMap.bind(this)); + this.app.get("/drift", this.renderDriftMonitor.bind(this)); this.app.get("/", this.renderDashboard.bind(this)); } /** - * Start the dashboard server + * Start only when SPECSYNC_DASHBOARD=1 and a secret is configured. + * Binds to loopback by default (DASHBOARD_HOST). */ start() { - this.app.listen(this.port, () => { - console.log(`SpecSync Dashboard running on http://localhost:${this.port}`); - }); - } - - /** - * Prompt 3.1 — Spec Coverage Map - * Goal: Tree view of repo with color-coded verification status. - */ - async getCoverageData(req, res) { - try { - const coverageData = await this.loadCoverageData(); - res.json(coverageData); - } catch (error) { - res.status(500).json({ error: error.message }); + if (process.env.SPECSYNC_DASHBOARD !== "1") { + console.log("SpecSync Dashboard not started (set SPECSYNC_DASHBOARD=1 to enable)"); + return null; + } + if (!this.secret) { + console.warn( + "SPECSYNC_DASHBOARD=1 but SPECSYNC_DASHBOARD_SECRET is unset; dashboard not started" + ); + return null; } + + this.server = this.app.listen(this.port, this.host, () => { + console.log( + `SpecSync Dashboard listening on http://${this.host}:${this.port} (secret auth required)` + ); + }); + return this.server; } - async renderCoverageMap(req, res) { - try { - const coverageData = await this.loadCoverageData(); - res.render("coverage-map", { - title: "Spec Coverage Map", - coverageData, - filters: { - author: req.query.author || "", - team: req.query.team || "", - module: req.query.module || "", - dateRange: req.query.dateRange || "all", - }, - }); - } catch (error) { - res.status(500).render("error", { error: error.message }); + stop() { + if (this.server) { + this.server.close(); + this.server = null; } } - /** - * Load coverage data from file or generate mock data - */ - async loadCoverageData() { + async getCoverageData(_req: any, res: any) { try { - const coveragePath = path.join(process.cwd(), ".specsync", "coverage.json"); - if (await fs.pathExists(coveragePath)) { - return await fs.readJson(coveragePath); - } - } catch (error) { - console.warn("Could not load coverage data:", error.message); + res.json(await this.loadCoverageData()); + } catch (error: unknown) { + res.status(500).json({ error: error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error) }); } - - // Generate mock coverage data - return this.generateMockCoverageData(); } - /** - * Generate mock coverage data for demonstration - */ - generateMockCoverageData() { - return { - summary: { - totalFunctions: 150, - coveredFunctions: 112, - coveragePercentage: 75, - lastUpdated: new Date().toISOString(), - }, - modules: [ - { - name: "src/auth", - coverage: 85, - functions: [ - { name: "validateToken", status: "verified", line: 15 }, - { name: "generateToken", status: "verified", line: 45 }, - { name: "refreshToken", status: "missing", line: 78 }, - ], - }, - { - name: "src/payment", - coverage: 60, - functions: [ - { name: "processPayment", status: "verified", line: 12 }, - { name: "refundPayment", status: "failed", line: 34 }, - { name: "validateCard", status: "missing", line: 67 }, - ], - }, - { - name: "src/user", - coverage: 90, - functions: [ - { name: "createUser", status: "verified", line: 23 }, - { name: "updateUser", status: "verified", line: 56 }, - { name: "deleteUser", status: "verified", line: 89 }, - ], - }, - ], - }; - } - - /** - * Prompt 3.2 — Spec Drift Monitor - * Goal: Timeline chart of drift events + alert panel. - */ - async getDriftData(req, res) { + async getDriftData(_req: any, res: any) { try { - const driftData = await this.loadDriftData(); - res.json(driftData); - } catch (error) { - res.status(500).json({ error: error.message }); + res.json(await this.loadDriftData()); + } catch (error: unknown) { + res.status(500).json({ error: error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error) }); } } - async renderDriftMonitor(req, res) { + async getStoriesData(_req: any, res: any) { try { - const driftData = await this.loadDriftData(); - res.render("drift-monitor", { - title: "Spec Drift Monitor", - driftData, - filters: { - severity: req.query.severity || "all", - dateRange: req.query.dateRange || "7d", - }, - }); - } catch (error) { - res.status(500).render("error", { error: error.message }); + res.json(await this.loadStoriesData()); + } catch (error: unknown) { + res.status(500).json({ error: error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error) }); } } - /** - * Load drift data - */ - async loadDriftData() { + async getAuditData(req: any, res: any) { try { - const driftPath = path.join(process.cwd(), ".specsync", "drift_events.json"); - if (await fs.pathExists(driftPath)) { - return await fs.readJson(driftPath); + const auditData = await this.loadAuditData(req.params.functionId); + if (!auditData) { + res.status(404).json({ error: "Audit record not found" }); + return; } - } catch (error) { - console.warn("Could not load drift data:", error.message); + res.json(auditData); + } catch (error: unknown) { + res.status(500).json({ error: error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error) }); } - - return this.generateMockDriftData(); } - /** - * Generate mock drift data - */ - generateMockDriftData() { - return { - timeline: [ - { - timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), - functionName: "withdraw", - module: "src/payment", - severity: "high", - reason: "Postcondition weakened", - author: "alice@company.com", - commit: "abc1234", - }, - { - timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), - functionName: "validateToken", - module: "src/auth", - severity: "medium", - reason: "Precondition changed", - author: "bob@company.com", - commit: "def5678", - }, - { - timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), - functionName: "processPayment", - module: "src/payment", - severity: "low", - reason: "Invariant updated", - author: "charlie@company.com", - commit: "ghi9012", - }, - ], - summary: { - totalDrifts: 3, - highSeverity: 1, - mediumSeverity: 1, - lowSeverity: 1, - resolved: 0, - pending: 3, - }, - }; + async getJobsMetrics(_req: any, res: any) { + res.json({ + source: "analysis-queue", + ...analysisJobQueue.getStats(), + }); } - /** - * Prompt 3.3 — Story ↔ Spec Linker - * Goal: Trace JIRA stories to formal specs and code. - */ - async getStoriesData(req, res) { + async listStoredSpecs(_req: any, res: any) { try { - const storiesData = await this.loadStoriesData(); - res.json(storiesData); - } catch (error) { - res.status(500).json({ error: error.message }); + res.json(await this.loadSpecsFromStore()); + } catch (error: unknown) { + res.status(500).json({ error: error instanceof Error ? (error instanceof Error ? error.message : String(error)) : String(error) }); } } - async renderStoryLinker(req, res) { + async renderCoverageMap(req: any, res: any) { try { - const storiesData = await this.loadStoriesData(); - res.render("story-linker", { - title: "Story ↔ Spec Linker", - storiesData, - }); - } catch (error) { - res.status(500).render("error", { error: error.message }); + const coverageData = await this.loadCoverageData(); + res.type("html").send(this.generateCoverageMapHTML(coverageData)); + } catch (error: unknown) { + res.status(500).type("html").send(`

${escapeHtml((error instanceof Error ? error.message : String(error)))}

`); } } - /** - * Load stories data - */ - async loadStoriesData() { + async renderDriftMonitor(_req: any, res: any) { try { - const storiesPath = path.join(process.cwd(), ".specsync", "story_map.json"); - if (await fs.pathExists(storiesPath)) { - return await fs.readJson(storiesPath); - } - } catch (error) { - console.warn("Could not load stories data:", error.message); + const driftData = await this.loadDriftData(); + res.type("html").send(this.generateDriftMonitorHTML(driftData)); + } catch (error: unknown) { + res.status(500).type("html").send(`

${escapeHtml((error instanceof Error ? error.message : String(error)))}

`); } - - return this.generateMockStoriesData(); - } - - /** - * Generate mock stories data - */ - generateMockStoriesData() { - return { - stories: [ - { - id: "PROJ-123", - title: "Implement secure payment processing", - status: "in-progress", - coverage: 75, - specs: [ - { - functionName: "processPayment", - theorem: "payment_security_theorem", - status: "verified", - }, - { functionName: "validateCard", theorem: "card_validation_theorem", status: "missing" }, - ], - driftWarnings: 1, - }, - { - id: "PROJ-124", - title: "Add user authentication", - status: "done", - coverage: 100, - specs: [ - { - functionName: "validateToken", - theorem: "token_validation_theorem", - status: "verified", - }, - { - functionName: "generateToken", - theorem: "token_generation_theorem", - status: "verified", - }, - ], - driftWarnings: 0, - }, - { - id: "PROJ-125", - title: "User profile management", - status: "todo", - coverage: 0, - specs: [], - driftWarnings: 0, - }, - ], - }; } - /** - * Prompt 3.4 — Audit Explorer - * Goal: Compliance-friendly modal showing complete proof lineage. - */ - async getAuditData(req, res) { + async renderDashboard(_req: any, res: any) { try { - const functionId = req.params.functionId; - const auditData = await this.loadAuditData(functionId); - res.json(auditData); - } catch (error) { - res.status(500).json({ error: error.message }); + const coverageData = await this.loadCoverageData(); + const driftData = await this.loadDriftData(); + const jobs = analysisJobQueue.getStats(); + res.type("html").send(this.generateDashboardHTML(coverageData, driftData, jobs)); + } catch (error: unknown) { + res.status(500).type("html").send(`

${escapeHtml((error instanceof Error ? error.message : String(error)))}

`); } } - async renderAuditExplorer(req, res) { - try { - const functionId = req.params.functionId; - const auditData = await this.loadAuditData(functionId); - res.render("audit-explorer", { - title: `Audit Explorer - ${functionId}`, - auditData, - }); - } catch (error) { - res.status(500).render("error", { error: error.message }); + async readSpecsyncJson(relativePath: any, fallback: any) { + const fullPath = path.join(process.cwd(), SPECSYNC_ROOT, relativePath); + if (!(await fs.pathExists(fullPath))) { + return { ...fallback, source: "empty" }; } + const data = await fs.readJson(fullPath); + return { ...data, source: `.specsync/${relativePath.replace(/\\/g, "/")}` }; } - /** - * Load audit data for a specific function - */ - async loadAuditData(functionId) { - try { - const auditPath = path.join(process.cwd(), ".specsync", "audit", `${functionId}.json`); - if (await fs.pathExists(auditPath)) { - return await fs.readJson(auditPath); + async loadCoverageData() { + const fromFile = await this.readSpecsyncJson("coverage.json", EMPTY_COVERAGE); + if (fromFile.source !== "empty") { + return fromFile; + } + // Derive a summary from accepted specs on disk when coverage.json is absent. + const specs = await this.loadSpecsFromStore(); + if (specs.specs.length === 0) { + return EMPTY_COVERAGE; + } + const byModule = new Map(); + for (const spec of specs.specs) { + const dir = path.posix.dirname(spec.filePath.replace(/\\/g, "/")) || "."; + if (!byModule.has(dir)) { + byModule.set(dir, { name: dir, coverage: 100, functions: [] }); } - } catch (error) { - console.warn("Could not load audit data:", error.message); + byModule.get(dir).functions.push({ + name: spec.functionName, + status: "verified", + line: spec.lineNumber || 0, + }); } - - return this.generateMockAuditData(functionId); - } - - /** - * Generate mock audit data - */ - generateMockAuditData(functionId) { return { - functionId, - lineage: [ - { - timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), - author: "alice@company.com", - commit: "abc1234", - theorem: `${functionId}_security_theorem`, - proofHash: "sha256:abc123def456", - status: "verified", - downloadUrl: `/download/proof/${functionId}_security_theorem.lean`, - }, - { - timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), - author: "bob@company.com", - commit: "def5678", - theorem: `${functionId}_correctness_theorem`, - proofHash: "sha256:def456ghi789", - status: "verified", - downloadUrl: `/download/proof/${functionId}_correctness_theorem.lean`, - }, - ], - metadata: { - sigstoreSignature: "sigstore:abc123def456", - verificationTimestamp: new Date().toISOString(), - proofArtifacts: [ - { - name: `${functionId}_security_theorem.lean`, - size: "2.3KB", - hash: "sha256:abc123def456", - }, - { - name: `${functionId}_correctness_theorem.lean`, - size: "1.8KB", - hash: "sha256:def456ghi789", - }, - ], + summary: { + totalFunctions: specs.specs.length, + coveredFunctions: specs.specs.length, + coveragePercentage: 100, + lastUpdated: new Date().toISOString(), }, + modules: Array.from(byModule.values()), + source: ".specsync/specs", }; } - /** - * Render main dashboard - */ - async renderDashboard(req, res) { - try { - const coverageData = await this.loadCoverageData(); - const driftData = await this.loadDriftData(); - const storiesData = await this.loadStoriesData(); + async loadDriftData() { + return this.readSpecsyncJson("drift_events.json", EMPTY_DRIFT); + } - res.render("dashboard", { - title: "SpecSync Dashboard", - coverageData, - driftData, - storiesData, - }); - } catch (error) { - res.status(500).render("error", { error: error.message }); + async loadStoriesData() { + return this.readSpecsyncJson("story_map.json", EMPTY_STORIES); + } + + async loadAuditData(functionId: any) { + const safeId = String(functionId || "").replace(/[^a-zA-Z0-9._-]/g, "_"); + const auditPath = path.join(process.cwd(), SPECSYNC_ROOT, "audit", `${safeId}.json`); + if (!(await fs.pathExists(auditPath))) { + return null; } + return fs.readJson(auditPath); } - /** - * Generate HTML templates for dashboard components - */ - generateCoverageMapHTML(data) { - return ` - + async loadSpecsFromStore() { + const specsDir = path.join(process.cwd(), SPECSYNC_ROOT, "specs"); + if (!(await fs.pathExists(specsDir))) { + return { specs: [], source: "empty" }; + } + const files = await fs.readdir(specsDir); + const specs = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const data = await fs.readJson(path.join(specsDir, file)); + specs.push({ + storeFile: file, + functionName: data.functionName, + filePath: data.filePath, + lineNumber: data.lineNumber, + confidence: data.confidence, + acceptedAt: data.acceptedAt, + pr: data.pr, + sha: data.sha, + }); + } catch { + // skip corrupt entries + } + } + return { specs, source: ".specsync/specs" }; + } + + generateDashboardHTML(coverage: any, drift: any, jobs: any) { + const summary = coverage.summary || EMPTY_COVERAGE.summary; + const driftSummary = drift.summary || EMPTY_DRIFT.summary; + return ` - - Spec Coverage Map + SpecSync Dashboard -

Spec Coverage Map

- -
-
- - -
-
- - +

SpecSync

+

Data source: ${escapeHtml(coverage.source || "empty")}

+
+
+

Coverage

+

${escapeHtml(summary.coveredFunctions)} / ${escapeHtml(summary.totalFunctions)} + (${escapeHtml(summary.coveragePercentage)}%)

+

Coverage map

-
- - +
+

Drift

+

Pending: ${escapeHtml(driftSummary.pending)} / Total: ${escapeHtml(driftSummary.totalDrifts)}

+

Drift monitor

-
- - +
+

Jobs

+

Running: ${escapeHtml(jobs.running)} / Total tracked: ${escapeHtml(jobs.total)}

+

${escapeHtml(JSON.stringify(jobs.byStatus || {}))}

- -
- ${this.generateTreeNodes(data.modules)} -
- - `; } - generateTreeNodes(modules) { - let html = ""; - - modules.forEach((module) => { - html += `
- 📁 ${module.name} - - ${module.coverage}% - -
`; - - module.functions.forEach((func) => { - html += `
- 📄 ${func.name} - ${func.status} -
`; - }); - }); - - return html; - } - - getCoverageColor(coverage) { - if (coverage >= 80) return "#28a745"; - if (coverage >= 60) return "#ffc107"; - return "#dc3545"; - } + generateCoverageMapHTML(data: any) { + const modules = data.modules || []; + const nodes = modules + .map((module: any) => { + const fns = (module.functions || []) + .map( + (func: any) => + `
${escapeHtml(func.name)} ` + + `${escapeHtml(func.status)}
` + ) + .join(""); + return `
${escapeHtml(module.name)} ` + + `(${escapeHtml(module.coverage)}%)${fns}
`; + }) + .join(""); - generateDriftMonitorHTML(data) { - return ` - + return ` - - - - Spec Drift Monitor - +Spec Coverage Map + -

Spec Drift Monitor

- -
-

Summary

-

Total Drifts: ${data.summary.totalDrifts}

-

High Severity: ${data.summary.highSeverity}

-

Medium Severity: ${data.summary.mediumSeverity}

-

Low Severity: ${data.summary.lowSeverity}

-

Resolved: ${data.summary.resolved}

-

Pending: ${data.summary.pending}

-
- -
- - - - - -
- -
- ${data.timeline - .map( - (item) => ` -
-
-
-

${item.functionName}

- ${item.severity.toUpperCase()} -
-

Module: ${item.module}

-

Reason: ${item.reason}

-

Author: ${item.author}

-

Commit: ${item.commit}

-

Time: ${new Date(item.timestamp).toLocaleString()}

-
-
- ` - ) - .join("")} -
- - +

Spec Coverage Map

+

Source: ${escapeHtml(data.source || "empty")}

+ ${nodes || "

No coverage data under .specsync/.

"} `; } - generateStoryLinkerHTML(data) { - return ` - - - - - - Story ↔ Spec Linker - - - -

Story ↔ Spec Linker

- - ${data.stories - .map( - (story) => ` -
-
-
- ${story.id} -

${story.title}

-
- ${story.status.toUpperCase()} -
- -
-

Coverage: ${story.coverage}%

-
-
-
- - ${story.driftWarnings > 0 ? `

⚠️ ${story.driftWarnings} drift warning(s)

` : ""} - -
-

Linked Specifications:

- ${ - story.specs.length > 0 - ? story.specs - .map( - (spec) => ` -
- ${spec.functionName} (${spec.theorem}) - ${spec.status} -
- ` - ) - .join("") - : "

No specifications linked

" - } -
- -
-

Add Mapping

-
- - -
-
-
-
- ` - ) - .join("")} - - - -`; - } + generateDriftMonitorHTML(data: any) { + const timeline = data.timeline || []; + const rows = timeline + .map( + (event: any) => + `
  • ${escapeHtml(event.functionName)} ` + + `(${escapeHtml(event.severity)}): ${escapeHtml(event.reason)} ` + + `${escapeHtml(event.timestamp || "")}
  • ` + ) + .join(""); - generateAuditExplorerHTML(data) { - return ` - + return ` - - - - Audit Explorer - ${data.functionId} - +Spec Drift Monitor + -

    Audit Explorer - ${data.functionId}

    - -
    -

    Proof Lineage

    - ${data.lineage - .map( - (item) => ` -
    -

    ${item.theorem}

    -

    Author: ${item.author}

    -

    Commit: ${item.commit}

    -

    Timestamp: ${new Date(item.timestamp).toLocaleString()}

    -

    Proof Hash: ${item.proofHash}

    -

    Status: ${item.status}

    - Download Proof -
    - ` - ) - .join("")} -
    - -
    -

    Metadata

    - -
    - - +

    Spec Drift Monitor

    +

    Source: ${escapeHtml(data.source || "empty")}

    +
      ${rows || "
    • No drift events under .specsync/drift_events.json.
    • "}
    `; } } + +(WebDashboard as any).escapeHtml = escapeHtml; diff --git a/test/analysis-queue.test.ts b/test/analysis-queue.test.ts new file mode 100644 index 0000000..8c71f9a --- /dev/null +++ b/test/analysis-queue.test.ts @@ -0,0 +1,108 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + AnalysisJobQueue, + buildAnalysisJobKey, + markerFileName, +} from "../src/analysis-queue"; +import type { AnalysisJobPayload } from "../src/types"; + +function payload(overrides: Partial = {}): AnalysisJobPayload { + return { + installationId: 1, + owner: "acme", + repo: "app", + pr: 42, + headSha: "aaa111", + ...overrides, + }; +} + +describe("AnalysisJobQueue", () => { + test("buildAnalysisJobKey includes owner/repo/pr/sha", () => { + expect(buildAnalysisJobKey(payload())).toBe("pr:acme/app/42:aaa111"); + }); + + test("skips duplicate completed or in-flight jobs for same key", async () => { + const queue = new AnalysisJobQueue({ maxConcurrent: 1 }); + let runs = 0; + + const first = queue.enqueue(payload(), async () => { + runs += 1; + await new Promise((r) => setTimeout(r, 40)); + }); + expect(first.enqueued).toBe(true); + + const second = queue.enqueue(payload(), async () => { + runs += 1; + }); + expect(second.enqueued).toBe(false); + expect(second.reason).toBe("in_flight"); + + await new Promise((r) => setTimeout(r, 80)); + expect(runs).toBe(1); + + const third = queue.enqueue(payload(), async () => { + runs += 1; + }); + expect(third.enqueued).toBe(false); + expect(third.reason).toBe("duplicate"); + }); + + test("supersedes older SHAs on synchronize", async () => { + const queue = new AnalysisJobQueue({ maxConcurrent: 1 }); + const order: string[] = []; + + queue.enqueue(payload({ headSha: "oldsha" }), async (job) => { + await new Promise((r) => setTimeout(r, 60)); + if (job.status === "superseded") { + return; + } + order.push(job.payload.headSha); + }); + + const next = queue.enqueue(payload({ headSha: "newsha" }), async (job) => { + order.push(job.payload.headSha); + }); + + expect(next.enqueued).toBe(true); + expect(next.reason).toBe("superseded_prior"); + + const old = queue.getJob(buildAnalysisJobKey(payload({ headSha: "oldsha" }))); + expect(old?.status).toBe("superseded"); + + await new Promise((r) => setTimeout(r, 100)); + expect(order).toEqual(["newsha"]); + }); + + test("optional job markers skip completed keys across queue instances", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "specsync-jobs-")); + try { + const queueA = new AnalysisJobQueue({ maxConcurrent: 1, markersDir: dir }); + let runs = 0; + + queueA.enqueue(payload(), async () => { + runs += 1; + }); + await new Promise((r) => setTimeout(r, 50)); + expect(runs).toBe(1); + + const key = buildAnalysisJobKey(payload()); + const markerPath = path.join(dir, markerFileName(key)); + expect(fs.existsSync(markerPath)).toBe(true); + const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); + expect(marker.status).toBe("completed"); + + const queueB = new AnalysisJobQueue({ maxConcurrent: 1, markersDir: dir }); + const again = queueB.enqueue(payload(), async () => { + runs += 1; + }); + expect(again.enqueued).toBe(false); + expect(again.reason).toBe("marker_completed"); + expect(runs).toBe(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/ast-extractor.test.ts b/test/ast-extractor.test.ts new file mode 100644 index 0000000..d32ed7d --- /dev/null +++ b/test/ast-extractor.test.ts @@ -0,0 +1,88 @@ +import { + ASTExtractor, + isSupportedAstPath, + getLanguageNameFromPath, + SUPPORTED_AST_EXTENSIONS, +} from "../src/ast-extractor"; + +describe("ASTExtractor language support", () => { + test("isSupportedAstPath accepts tree-sitter-backed extensions only", () => { + expect(isSupportedAstPath("src/app.ts")).toBe(true); + expect(isSupportedAstPath("src/app.js")).toBe(true); + expect(isSupportedAstPath("src/app.py")).toBe(true); + expect(isSupportedAstPath("src/Main.java")).toBe(true); + expect(isSupportedAstPath("src/lib.rs")).toBe(true); + expect(isSupportedAstPath("src/main.go")).toBe(false); + expect(isSupportedAstPath("src/util.c")).toBe(false); + expect(isSupportedAstPath("src/util.cpp")).toBe(false); + }); + + test("getLanguageNameFromPath maps known extensions", () => { + expect(getLanguageNameFromPath("a.ts")).toBe("TypeScript"); + expect(getLanguageNameFromPath("a.py")).toBe("Python"); + expect(getLanguageNameFromPath("a.go")).toBe("Unknown"); + }); + + test("SUPPORTED_AST_EXTENSIONS does not claim Go/C/C++", () => { + expect(SUPPORTED_AST_EXTENSIONS).not.toContain(".go"); + expect(SUPPORTED_AST_EXTENSIONS).not.toContain(".c"); + expect(SUPPORTED_AST_EXTENSIONS).not.toContain(".cpp"); + }); + + test("createBasicAnalysis preserves side and line metadata", () => { + const extractor = new ASTExtractor(); + const analysis = extractor.createBasicAnalysis({ + filePath: "src/old.js", + functionName: "doomed", + functionBody: "-function doomed() {}", + lineNumber: 8, + side: "LEFT", + changeType: "removed", + }); + + expect(analysis.side).toBe("LEFT"); + expect(analysis.lineNumber).toBe(8); + expect(analysis.ast.complexity).toBe(1); + }); + + test("getFileContent caches by path+ref", async () => { + const extractor = new ASTExtractor(); + const getContent = jest.fn().mockResolvedValue({ + data: { content: Buffer.from("function add() {}").toString("base64") }, + }); + const context = { + payload: { + repository: { owner: { login: "acme" }, name: "app" }, + pull_request: { head: { sha: "deadbeef" } }, + }, + octokit: { repos: { getContent } }, + }; + + const first = await extractor.getFileContent("src/a.js", context); + const second = await extractor.getFileContent("src/a.js", context); + expect(first).toBe("function add() {}"); + expect(second).toBe("function add() {}"); + expect(getContent).toHaveBeenCalledTimes(1); + + extractor.clearContentCache(); + await extractor.getFileContent("src/a.js", context); + expect(getContent).toHaveBeenCalledTimes(2); + }); + + test("extractAST skips unsupported languages", async () => { + const extractor = new ASTExtractor(); + const results = await extractor.extractAST( + [ + { + filePath: "src/main.go", + functionName: "main", + functionBody: "func main() {}", + lineNumber: 1, + side: "RIGHT", + }, + ], + { payload: {}, octokit: {} } + ); + expect(results).toEqual([]); + }); +}); diff --git a/test/command-rate-limit.test.ts b/test/command-rate-limit.test.ts new file mode 100644 index 0000000..d3a5189 --- /dev/null +++ b/test/command-rate-limit.test.ts @@ -0,0 +1,95 @@ +import { + CommandRateLimiter, + commandRateLimiter, +} from "../src/command-rate-limit"; +import { GitHubUI } from "../src/github-ui"; +import { resetMetricsForTests, getMetrics } from "../src/metrics"; + +describe("CommandRateLimiter", () => { + test("allows up to max then blocks within the window", () => { + const limiter = new CommandRateLimiter({ max: 2, windowMs: 60_000 }); + expect(limiter.allow("alice", "acme/app#1").allowed).toBe(true); + expect(limiter.allow("alice", "acme/app#1").allowed).toBe(true); + const blocked = limiter.allow("alice", "acme/app#1"); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfterMs).toBeGreaterThan(0); + }); + + test("scopes by actor and PR independently", () => { + const limiter = new CommandRateLimiter({ max: 1, windowMs: 60_000 }); + expect(limiter.allow("alice", "acme/app#1").allowed).toBe(true); + expect(limiter.allow("bob", "acme/app#1").allowed).toBe(true); + expect(limiter.allow("alice", "acme/app#2").allowed).toBe(true); + expect(limiter.allow("alice", "acme/app#1").allowed).toBe(false); + }); +}); + +describe("GitHubUI rate limit on mutating commands", () => { + beforeEach(() => { + commandRateLimiter.clearForTests(); + resetMetricsForTests(); + }); + + test("handleSpecCommentAction rejects when rate limited", async () => { + const githubUI = new GitHubUI(); + const octokit = { + pulls: { + get: jest.fn().mockResolvedValue({ + data: { + number: 42, + user: { login: "pr-author" }, + head: { ref: "feature", sha: "abc" }, + }, + }), + listCommits: jest.fn().mockResolvedValue({ data: [] }), + createReplyForReviewComment: jest.fn().mockResolvedValue({ id: 1 }), + }, + issues: { + createComment: jest.fn().mockResolvedValue({ id: 1 }), + }, + repos: { + getCollaboratorPermissionLevel: jest.fn().mockResolvedValue({ + data: { permission: "admin" }, + }), + getContent: jest.fn().mockRejectedValue({ status: 404 }), + createOrUpdateFileContents: jest.fn().mockResolvedValue({ + data: { content: { sha: "x" } }, + }), + }, + }; + + const context = { + payload: { + sender: { login: "pr-author" }, + repository: { owner: { login: "acme" }, name: "app" }, + issue: { number: 42, pull_request: {} }, + pull_request: { number: 42 }, + }, + octokit, + }; + + // Exhaust default limiter (max 10) — use a tight custom window via direct limiter. + // The shared limiter defaults to 10; burn them then assert. + for (let i = 0; i < 10; i++) { + expect(commandRateLimiter.allow("pr-author", "acme/app#42").allowed).toBe(true); + } + + await githubUI.handleSpecCommentAction(context, { + body: "/specsync accept", + user: { login: "pr-author" }, + id: 99, + path: "src/a.js", + }); + + expect(getMetrics().commands_rate_limited).toBe(1); + const replies = + octokit.pulls.createReplyForReviewComment.mock.calls.length + + octokit.issues.createComment.mock.calls.length; + expect(replies).toBeGreaterThan(0); + const body = + octokit.pulls.createReplyForReviewComment.mock.calls[0]?.[0]?.body || + octokit.issues.createComment.mock.calls[0]?.[0]?.body; + expect(body).toContain("Rate limit"); + expect(octokit.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + }); +}); diff --git a/test/diff-parser.test.ts b/test/diff-parser.test.ts index 94035ba..32f6c26 100644 --- a/test/diff-parser.test.ts +++ b/test/diff-parser.test.ts @@ -20,10 +20,17 @@ describe("DiffParser", () => { expect(diffParser.isCodeFile("test/app.js")).toBe(false); }); - it("should exclude non-code files", () => { - expect(diffParser.isCodeFile("README.md")).toBe(false); - expect(diffParser.isCodeFile("package.json")).toBe(false); - expect(diffParser.isCodeFile("src/app.css")).toBe(false); + it("should exclude languages without tree-sitter parsers", () => { + expect(diffParser.isCodeFile("src/main.go")).toBe(false); + expect(diffParser.isCodeFile("src/util.c")).toBe(false); + expect(diffParser.isCodeFile("src/util.cpp")).toBe(false); + expect(diffParser.isCodeFile("include/util.h")).toBe(false); + }); + + it("should include tree-sitter-backed languages", () => { + expect(diffParser.isCodeFile("src/App.tsx")).toBe(true); + expect(diffParser.isCodeFile("src/Main.java")).toBe(true); + expect(diffParser.isCodeFile("src/lib.rs")).toBe(true); }); }); @@ -47,6 +54,98 @@ index 1234567..abcdefg 100644 expect(functions).toHaveLength(1); expect(functions[0].name).toBe("calculateInterest"); expect(functions[0].changeType).toBe("+"); + expect(functions[0].lineNumber).toBe(10); + }); + + it("should map function start to RIGHT-side file line after hunk context", () => { + const diff = `@@ -20,3 +20,7 @@ + context line one + context line two ++function addedAfterContext(x) { ++ return x; ++} +`; + + const functions = diffParser.extractFunctions(diff); + expect(functions).toHaveLength(1); + expect(functions[0].name).toBe("addedAfterContext"); + // Hunk starts at right line 20; two context lines → function at 22 + expect(functions[0].lineNumber).toBe(22); + }); + + it("should skip removed lines when advancing RIGHT-side line numbers", () => { + const diff = `@@ -5,4 +5,4 @@ +-function oldName() { ++function newName() { + return 1; + } +`; + + const functions = diffParser.extractFunctions(diff); + const added = functions.find((f) => f.name === "newName"); + expect(added).toBeDefined(); + expect(added.lineNumber).toBe(5); + }); + + it("should parse hunk headers", () => { + expect(diffParser.parseHunkHeader("@@ -10,6 +12,8 @@")).toEqual({ + oldStart: 10, + oldCount: 6, + newStart: 12, + newCount: 8, + }); + expect(diffParser.parseHunkHeader("@@ -1 +1 @@")).toEqual({ + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + }); + expect(diffParser.parseHunkHeader("@@ -1,5 +0,0 @@")).toEqual({ + oldStart: 1, + oldCount: 5, + newStart: 0, + newCount: 0, + }); + expect(diffParser.parseHunkHeader("not a hunk")).toBeNull(); + }); + + it("should anchor pure deletions to LEFT-side old-file lines", () => { + const diff = `diff --git a/src/old.js b/src/old.js +deleted file mode 100644 +index abcdef1..0000000 +--- a/src/old.js ++++ /dev/null +@@ -8,5 +0,0 @@ +-function doomed() { +- return 1; +-} +`; + + const functions = diffParser.extractFunctions(diff); + expect(functions).toHaveLength(1); + expect(functions[0].name).toBe("doomed"); + expect(functions[0].side).toBe("LEFT"); + expect(functions[0].lineNumber).toBe(8); + expect(functions[0].changeType).toBe("-"); + }); + + it("should use LEFT for removed function lines in mixed hunks", () => { + const diff = `@@ -5,4 +5,4 @@ +-function oldName() { ++function newName() { + return 1; + } +`; + + const functions = diffParser.extractFunctions(diff); + const removed = functions.find((f: { name: string }) => f.name === "oldName"); + const added = functions.find((f: { name: string }) => f.name === "newName"); + expect(removed).toBeDefined(); + expect(removed.side).toBe("LEFT"); + expect(removed.lineNumber).toBe(5); + expect(added).toBeDefined(); + expect(added.side).toBe("RIGHT"); + expect(added.lineNumber).toBe(5); }); it("should extract arrow functions", () => { @@ -64,6 +163,7 @@ index 1234567..abcdefg 100644 const functions = diffParser.extractFunctions(diff); expect(functions).toHaveLength(1); expect(functions[0].name).toBe("multiply"); + expect(functions[0].lineNumber).toBe(5); }); it("should extract Python function definitions", () => { @@ -82,6 +182,7 @@ index 1234567..abcdefg 100644 const functions = diffParser.extractFunctions(diff); expect(functions.length).toBeGreaterThan(0); expect(functions[0].name).toBe("calculate_area"); + expect(functions[0].lineNumber).toBe(3); }); }); diff --git a/test/fixtures/pull_request.opened.json b/test/fixtures/pull_request.opened.json index 9cf929d..94ff2d5 100644 --- a/test/fixtures/pull_request.opened.json +++ b/test/fixtures/pull_request.opened.json @@ -1,5 +1,6 @@ { "action": "opened", + "installation": { "id": 99 }, "repository": { "name": "demo-repo", "full_name": "acme/demo-repo", @@ -8,6 +9,7 @@ "pull_request": { "number": 42, "html_url": "https://github.com/acme/demo-repo/pull/42", - "head": { "sha": "deadbeef" } + "head": { "sha": "deadbeefcafebabe0123456789abcdef01234567", "ref": "feature/specs" }, + "base": { "ref": "main" } } } diff --git a/test/fixtures/stored-spec.add.json b/test/fixtures/stored-spec.add.json new file mode 100644 index 0000000..c9f790b --- /dev/null +++ b/test/fixtures/stored-spec.add.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "functionName": "add", + "filePath": "src/math.js", + "lineNumber": 1, + "acceptedAt": "2026-01-01T00:00:00.000Z", + "pr": 7, + "sha": "abc123", + "confidence": 0.9, + "contract": { + "preconditions": ["a is number", "b is number"], + "postconditions": ["returns a + b"], + "invariants": [], + "edgeCases": [], + "reasoning": "simple addition" + }, + "implementationHash": "PLACEHOLDER" +} diff --git a/test/github-ui-authz.test.ts b/test/github-ui-authz.test.ts new file mode 100644 index 0000000..39d7ee4 --- /dev/null +++ b/test/github-ui-authz.test.ts @@ -0,0 +1,123 @@ +import { GitHubUI } from "../src/github-ui"; + +describe("GitHubUI comment AuthZ", () => { + let githubUI: GitHubUI; + let octokit: any; + let context: any; + + beforeEach(() => { + githubUI = new GitHubUI(); + octokit = { + pulls: { + get: jest.fn().mockResolvedValue({ + data: { + number: 42, + user: { login: "pr-author" }, + head: { ref: "feature", sha: "abc123" }, + }, + }), + listCommits: jest.fn().mockResolvedValue({ + data: [{ author: { login: "committer-user" }, committer: { login: "committer-user" } }], + }), + createReplyForReviewComment: jest.fn().mockResolvedValue({ id: 1 }), + }, + issues: { + createComment: jest.fn().mockResolvedValue({ id: 1 }), + }, + repos: { + getCollaboratorPermissionLevel: jest.fn().mockResolvedValue({ + data: { permission: "read" }, + }), + getContent: jest.fn().mockRejectedValue({ status: 404 }), + createOrUpdateFileContents: jest.fn().mockResolvedValue({ + data: { content: { sha: "x" } }, + }), + }, + }; + + context = { + payload: { + sender: { login: "random-reader" }, + repository: { + owner: { login: "acme" }, + name: "app", + }, + issue: { number: 42, pull_request: {} }, + pull_request: { number: 42 }, + }, + octokit, + }; + }); + + test("authorizeSpecMutation allows PR author", async () => { + context.payload.sender.login = "pr-author"; + const result = await githubUI.authorizeSpecMutation(context, { + body: "/specsync accept", + user: { login: "pr-author" }, + }); + expect(result.allowed).toBe(true); + expect(result.reason).toBe("pr_author"); + }); + + test("authorizeSpecMutation allows write collaborators", async () => { + octokit.repos.getCollaboratorPermissionLevel.mockResolvedValue({ + data: { permission: "write" }, + }); + const result = await githubUI.authorizeSpecMutation(context, { + body: "/specsync ignore", + user: { login: "random-reader" }, + }); + expect(result.allowed).toBe(true); + expect(result.reason).toBe("permission:write"); + }); + + test("authorizeSpecMutation allows PR committers", async () => { + context.payload.sender.login = "committer-user"; + const result = await githubUI.authorizeSpecMutation(context, { + body: "/specsync edit", + user: { login: "committer-user" }, + }); + expect(result.allowed).toBe(true); + expect(result.reason).toBe("pr_committer"); + }); + + test("authorizeSpecMutation denies readers without commits", async () => { + const result = await githubUI.authorizeSpecMutation(context, { + body: "/specsync accept", + user: { login: "random-reader" }, + }); + expect(result.allowed).toBe(false); + }); + + test("handleSpecCommentAction rejects unauthorized accept", async () => { + await githubUI.handleSpecCommentAction(context, { + id: 9, + body: "/specsync accept\n## Spec for `foo`", + user: { login: "random-reader" }, + path: "src/a.ts", + }); + + expect(octokit.pulls.createReplyForReviewComment).toHaveBeenCalled(); + const reply = octokit.pulls.createReplyForReviewComment.mock.calls[0][0]; + expect(reply.body).toContain("not allowed"); + expect(octokit.repos.createOrUpdateFileContents).not.toHaveBeenCalled(); + }); + + test("formatSpecComment escapes HTML in user-controlled fields", () => { + const body = githubUI.formatSpecComment({ + functionName: '', + filePath: 'src/.ts', + lineNumber: 3, + preconditions: ["x < 0 && y > 1"], + postconditions: ['ret == ""'], + invariants: [], + confidence: 0.9, + reasoning: "Uses tags", + }); + + expect(body).toContain("<script>"); + expect(body).not.toContain(" - -`; +`; } - -export function deactivate() {} diff --git a/vscode-extension/src/proof-runner.ts b/vscode-extension/src/proof-runner.ts index 715c2f7..f51d86d 100644 --- a/vscode-extension/src/proof-runner.ts +++ b/vscode-extension/src/proof-runner.ts @@ -1,190 +1,238 @@ -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as path from 'path'; -import { exec, spawn } from 'child_process'; - -export function activate(context: vscode.ExtensionContext) { - let currentProofProcess: any = null; - - context.subscriptions.push( - vscode.commands.registerCommand('specsync.runProof', () => runProof(currentProofProcess)), - vscode.commands.registerCommand('specsync.cancelProof', () => cancelProof(currentProofProcess)) - ); +import { spawn, type ChildProcess } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; + +export interface ProofFileResult { + file: string; + success: boolean; + output?: string; + error?: string; } -async function runProof(currentProcess: any) { - const config = vscode.workspace.getConfiguration('specsync'); - const leanPath = config.get('leanPath', 'lean'); - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showErrorMessage('No workspace folder found'); - return; +/** + * Runs Lean on `specs/` (and optional `.specsync/lean/`) with cancellable progress. + * Single instance — cancelProof kills the active child process. + */ +export class ProofRunner { + private currentProcess: ChildProcess | null = null; + private readonly outputChannel: vscode.OutputChannel; + + constructor() { + this.outputChannel = vscode.window.createOutputChannel("SpecSync Proof Runner"); } - - const specsDir = path.join(workspaceFolder.uri.fsPath, 'specs'); - if (!fs.existsSync(specsDir)) { - vscode.window.showErrorMessage('No specs directory found'); - return; + + dispose(): void { + this.cancel(); + this.outputChannel.dispose(); } - - const leanFiles = fs.readdirSync(specsDir).filter(f => f.endsWith('.lean')); - if (leanFiles.length === 0) { - vscode.window.showInformationMessage('No Lean files found in specs directory'); - return; + + cancel(): void { + if (this.currentProcess) { + this.currentProcess.kill(); + this.currentProcess = null; + this.outputChannel.appendLine("Proof execution cancelled"); + vscode.window.showInformationMessage("Proof execution cancelled"); + } else { + vscode.window.showInformationMessage("No proof run in progress"); + } } - - const progressOptions = { - location: vscode.ProgressLocation.Notification, - title: 'Running Lean Proofs', - cancellable: true - }; - - const outputChannel = vscode.window.createOutputChannel('SpecSync Proof Runner'); - outputChannel.show(); - - await vscode.window.withProgress(progressOptions, async (progress, token) => { - try { - progress.report({ message: 'Initializing Lean environment...' }); - - let successCount = 0; - let totalCount = leanFiles.length; - const results: any[] = []; - - for (const file of leanFiles) { - if (token.isCancellationRequested) { - outputChannel.appendLine('Proof execution cancelled by user'); - break; - } - - const filePath = path.join(specsDir, file); - progress.report({ - message: `Processing ${file}...`, - increment: 100 / totalCount - }); - - outputChannel.appendLine(`\nProcessing ${file}...`); - - try { - const result = await executeLeanFile(leanPath, filePath, outputChannel); - results.push({ file, success: result.success, output: result.output }); - - if (result.success) { - successCount++; - outputChannel.appendLine(`✅ ${file} - Proof successful`); - } else { - outputChannel.appendLine(`❌ ${file} - Proof failed`); + + async run(): Promise { + const config = vscode.workspace.getConfiguration("specsync"); + const leanPath = config.get("leanPath", "lean"); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage("No workspace folder found"); + return; + } + + const root = workspaceFolder.uri.fsPath; + const leanFiles = collectLeanFiles(root); + if (leanFiles.length === 0) { + vscode.window.showInformationMessage( + "No Lean files found under specs/ or .specsync/lean/" + ); + return; + } + + this.outputChannel.clear(); + this.outputChannel.show(true); + + const progressOptions: vscode.ProgressOptions = { + location: vscode.ProgressLocation.Notification, + title: "Running Lean Proofs", + cancellable: true, + }; + + await vscode.window.withProgress(progressOptions, async (progress, token) => { + const cancelListener = token.onCancellationRequested(() => { + this.cancel(); + }); + + try { + progress.report({ message: "Initializing Lean environment..." }); + + let successCount = 0; + const results: ProofFileResult[] = []; + const totalCount = leanFiles.length; + + for (const filePath of leanFiles) { + if (token.isCancellationRequested) { + this.outputChannel.appendLine("Proof execution cancelled by user"); + break; + } + + const file = path.relative(root, filePath); + progress.report({ + message: `Processing ${file}...`, + increment: 100 / totalCount, + }); + this.outputChannel.appendLine(`\nProcessing ${file}...`); + + try { + const result = await this.executeLeanFile(leanPath, filePath); + results.push({ file, success: result.success, output: result.output }); + if (result.success) { + successCount++; + this.outputChannel.appendLine(`OK ${file}`); + } else { + this.outputChannel.appendLine(`FAIL ${file}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + results.push({ file, success: false, error: message }); + this.outputChannel.appendLine(`FAIL ${file} — ${message}`); } - } catch (error) { - results.push({ file, success: false, error: error.message }); - outputChannel.appendLine(`❌ ${file} - Error: ${error.message}`); } - } - - const coverage = Math.round((successCount / totalCount) * 100); - - // Show results summary - const summary = ` -## Proof Execution Summary - -**Coverage:** ${coverage}% -**Successful:** ${successCount}/${totalCount} -**Failed:** ${totalCount - successCount} - -### Results: -${results.map(r => `${r.success ? '✅' : '❌'} ${r.file}`).join('\n')} -`; - - outputChannel.appendLine(summary); - - // Show notification - if (coverage >= 70) { - vscode.window.showInformationMessage( - `Proof validation complete: ${successCount}/${totalCount} successful (${coverage}% coverage)` - ); - } else { - vscode.window.showWarningMessage( - `Proof validation complete: ${successCount}/${totalCount} successful (${coverage}% coverage) - Below threshold` - ); - } - - // Generate artifacts - await generateProofArtifacts(results, workspaceFolder.uri.fsPath); - - } catch (error) { - outputChannel.appendLine(`\n❌ Proof execution failed: ${error.message}`); - vscode.window.showErrorMessage(`Proof execution failed: ${error.message}`); - } - }); -} -function executeLeanFile(leanPath: string, filePath: string, outputChannel: vscode.OutputChannel): Promise<{success: boolean, output: string}> { - return new Promise((resolve, reject) => { - const process = spawn(leanPath, ['--json', filePath], { - stdio: ['pipe', 'pipe', 'pipe'] - }); - - let stdout = ''; - let stderr = ''; - - process.stdout.on('data', (data) => { - stdout += data.toString(); - outputChannel.append(data.toString()); - }); - - process.stderr.on('data', (data) => { - stderr += data.toString(); - outputChannel.append(data.toString()); - }); - - process.on('close', (code) => { - if (code === 0) { - resolve({ success: true, output: stdout }); - } else { - resolve({ success: false, output: stderr }); + const coverage = + results.length === 0 + ? 0 + : Math.round((successCount / results.length) * 100); + const summary = [ + "", + "## Proof Execution Summary", + `Coverage: ${coverage}%`, + `Successful: ${successCount}/${results.length}`, + `Failed: ${results.length - successCount}`, + "", + ...results.map((r) => `${r.success ? "OK" : "FAIL"} ${r.file}`), + ].join("\n"); + + this.outputChannel.appendLine(summary); + + const message = `Proof validation complete: ${successCount}/${results.length} successful (${coverage}% coverage)`; + if (coverage >= 70) { + vscode.window.showInformationMessage(message); + } else { + vscode.window.showWarningMessage(`${message} — below threshold`); + } + + await writeProofArtifacts(results, root); + } finally { + cancelListener.dispose(); + this.currentProcess = null; } }); - - process.on('error', (error) => { - reject(error); + } + + private executeLeanFile( + leanPath: string, + filePath: string + ): Promise<{ success: boolean; output: string }> { + return new Promise((resolve, reject) => { + const child = spawn(leanPath, ["--json", filePath], { + stdio: ["ignore", "pipe", "pipe"], + }); + this.currentProcess = child; + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + const text = data.toString(); + stdout += text; + this.outputChannel.append(text); + }); + + child.stderr.on("data", (data: Buffer) => { + const text = data.toString(); + stderr += text; + this.outputChannel.append(text); + }); + + child.on("close", (code) => { + if (this.currentProcess === child) { + this.currentProcess = null; + } + if (code === 0) { + resolve({ success: true, output: stdout }); + } else { + resolve({ success: false, output: stderr || stdout }); + } + }); + + child.on("error", (error) => { + if (this.currentProcess === child) { + this.currentProcess = null; + } + reject(error); + }); }); - }); + } } -async function generateProofArtifacts(results: any[], workspacePath: string) { - const artifactsDir = path.join(workspacePath, '.specsync', 'artifacts'); - - // Ensure artifacts directory exists +function collectLeanFiles(workspaceRoot: string): string[] { + const dirs = [ + path.join(workspaceRoot, "specs"), + path.join(workspaceRoot, ".specsync", "lean"), + ]; + const files: string[] = []; + for (const dir of dirs) { + if (!fs.existsSync(dir)) { + continue; + } + for (const name of fs.readdirSync(dir)) { + if (name.endsWith(".lean")) { + files.push(path.join(dir, name)); + } + } + } + return files; +} + +async function writeProofArtifacts( + results: ProofFileResult[], + workspacePath: string +): Promise { + const artifactsDir = path.join(workspacePath, ".specsync", "artifacts"); if (!fs.existsSync(artifactsDir)) { fs.mkdirSync(artifactsDir, { recursive: true }); } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const reportPath = path.join(artifactsDir, `proof-report-${timestamp}.json`); - + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const report = { timestamp: new Date().toISOString(), totalFiles: results.length, - successfulProofs: results.filter(r => r.success).length, - failedProofs: results.filter(r => !r.success).length, - coverage: Math.round((results.filter(r => r.success).length / results.length) * 100), - results: results + successfulProofs: results.filter((r) => r.success).length, + failedProofs: results.filter((r) => !r.success).length, + coverage: + results.length === 0 + ? 0 + : Math.round((results.filter((r) => r.success).length / results.length) * 100), + results, }; - - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - - // Create a summary file - const summaryPath = path.join(artifactsDir, 'latest-report.json'); - fs.writeFileSync(summaryPath, JSON.stringify(report, null, 2)); - - vscode.window.showInformationMessage(`Proof artifacts saved to ${artifactsDir}`); -} -function cancelProof(currentProcess: any) { - if (currentProcess) { - currentProcess.kill(); - vscode.window.showInformationMessage('Proof execution cancelled'); - } -} + fs.writeFileSync( + path.join(artifactsDir, `proof-report-${timestamp}.json`), + JSON.stringify(report, null, 2) + ); + fs.writeFileSync( + path.join(artifactsDir, "latest-report.json"), + JSON.stringify(report, null, 2) + ); -export function deactivate() {} + vscode.window.showInformationMessage(`Proof artifacts saved to ${artifactsDir}`); +} diff --git a/vscode-extension/src/spec-reader.ts b/vscode-extension/src/spec-reader.ts new file mode 100644 index 0000000..ea107a8 --- /dev/null +++ b/vscode-extension/src/spec-reader.ts @@ -0,0 +1,310 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; + +/** Confidence in [0, 1] only. Values > 1 are treated as 0–100 percentages. */ +export type Confidence = number; + +export interface SpecContract { + preconditions: string[]; + postconditions: string[]; + invariants: string[]; + edgeCases?: string[]; + reasoning?: string; +} + +/** On-disk shape under `.specsync/specs/{file}__{fn}.json` (matches root SpecStore). */ +export interface StoredSpec { + version: number; + functionName: string; + filePath: string; + lineNumber?: number; + acceptedAt: string; + pr?: number; + sha?: string; + confidence: Confidence; + contract: SpecContract; + implementationHash?: string; +} + +/** Flat view model used by CodeLens / hover / webviews. */ +export interface SpecViewModel { + functionName: string; + filePath: string; + confidence: Confidence; + preconditions: string[]; + postconditions: string[]; + invariants: string[]; + edgeCases: string[]; + reasoning: string; + storePath: string; + acceptedAt?: string; +} + +export const SPECSYNC_SPECS_DIR = path.join(".specsync", "specs"); + +export function normalizeConfidence(value: unknown, fallback = 0.5): Confidence { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback; + } + const scaled = value > 1 ? value / 100 : value; + return Math.min(1, Math.max(0, scaled)); +} + +function sanitizePathSegment(value: string): string { + return value + .replace(/\\/g, "/") + .replace(/[^a-zA-Z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +/** Same path convention as root `buildSpecStorePath`. */ +export function buildSpecStorePath(filePath: string, functionName: string): string { + const safeFile = sanitizePathSegment(filePath.replace(/\\/g, "/").replace(/\//g, "__")); + const safeFn = sanitizePathSegment(functionName); + return path.join(SPECSYNC_SPECS_DIR, `${safeFile}__${safeFn}.json`); +} + +function toViewModel(stored: StoredSpec, storePath: string): SpecViewModel { + const contract = stored.contract || { + preconditions: [], + postconditions: [], + invariants: [], + reasoning: "", + }; + return { + functionName: stored.functionName, + filePath: stored.filePath, + confidence: normalizeConfidence(stored.confidence, 0), + preconditions: contract.preconditions || [], + postconditions: contract.postconditions || [], + invariants: contract.invariants || [], + edgeCases: contract.edgeCases || [], + reasoning: contract.reasoning || "", + storePath, + acceptedAt: stored.acceptedAt, + }; +} + +function parseStoredSpec(raw: unknown, storePath: string): SpecViewModel | null { + if (!raw || typeof raw !== "object") { + return null; + } + const obj = raw as Record; + + // Support legacy flat cache entries if present. + if (!obj.contract && (obj.preconditions || obj.postconditions || obj.invariants)) { + const legacy: StoredSpec = { + version: 0, + functionName: String(obj.functionName || ""), + filePath: String(obj.filePath || ""), + acceptedAt: String(obj.acceptedAt || ""), + confidence: normalizeConfidence(obj.confidence, 0), + contract: { + preconditions: Array.isArray(obj.preconditions) ? (obj.preconditions as string[]) : [], + postconditions: Array.isArray(obj.postconditions) ? (obj.postconditions as string[]) : [], + invariants: Array.isArray(obj.invariants) ? (obj.invariants as string[]) : [], + reasoning: String(obj.reasoning || ""), + }, + }; + return toViewModel(legacy, storePath); + } + + if (typeof obj.functionName !== "string" || !obj.contract) { + return null; + } + + return toViewModel(obj as unknown as StoredSpec, storePath); +} + +function getSpecsRoot(workspaceRoot: string): string { + const config = vscode.workspace.getConfiguration("specsync"); + const configured = config.get("specsDir", SPECSYNC_SPECS_DIR); + return path.isAbsolute(configured) + ? configured + : path.join(workspaceRoot, configured); +} + +function getConfidenceThreshold(): number { + const config = vscode.workspace.getConfiguration("specsync"); + return normalizeConfidence(config.get("confidenceThreshold", 0.7), 0.7); +} + +export function workspaceRootFor(filePath: string): string | null { + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); + return folder?.uri.fsPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null; +} + +export function relativeSourcePath(workspaceRoot: string, absoluteFilePath: string): string { + return path.relative(workspaceRoot, absoluteFilePath).replace(/\\/g, "/"); +} + +/** List all accepted specs under `.specsync/specs/`. */ +export function listSpecs(workspaceRoot: string): SpecViewModel[] { + const specsRoot = getSpecsRoot(workspaceRoot); + if (!fs.existsSync(specsRoot)) { + return []; + } + + const results: SpecViewModel[] = []; + for (const name of fs.readdirSync(specsRoot)) { + if (!name.endsWith(".json")) { + continue; + } + const full = path.join(specsRoot, name); + try { + const parsed = JSON.parse(fs.readFileSync(full, "utf8")); + const view = parseStoredSpec(parsed, full); + if (view) { + results.push(view); + } + } catch { + // Skip corrupt files + } + } + return results; +} + +/** + * Resolve a spec for a function in a source file. + * Prefers exact `.specsync/specs/{file}__{fn}.json`, then any match by function name. + */ +export function getSpecForFunction( + functionName: string, + sourceFilePath: string, + options?: { respectThreshold?: boolean } +): SpecViewModel | null { + const workspaceRoot = workspaceRootFor(sourceFilePath); + if (!workspaceRoot) { + return null; + } + + const respectThreshold = options?.respectThreshold !== false; + const threshold = getConfidenceThreshold(); + const rel = relativeSourcePath(workspaceRoot, sourceFilePath); + const exactRel = buildSpecStorePath(rel, functionName); + const exactAbs = path.join(workspaceRoot, exactRel); + + const candidates: SpecViewModel[] = []; + + if (fs.existsSync(exactAbs)) { + try { + const view = parseStoredSpec(JSON.parse(fs.readFileSync(exactAbs, "utf8")), exactAbs); + if (view) { + candidates.push(view); + } + } catch { + // fall through + } + } + + if (candidates.length === 0) { + for (const spec of listSpecs(workspaceRoot)) { + if (spec.functionName === functionName) { + const pathMatch = + !spec.filePath || + spec.filePath.replace(/\\/g, "/") === rel || + path.basename(spec.filePath) === path.basename(sourceFilePath); + if (pathMatch) { + candidates.push(spec); + } + } + } + } + + const match = candidates[0] ?? null; + if (!match) { + return null; + } + if (respectThreshold && match.confidence < threshold) { + return null; + } + return match; +} + +/** Persist an edited view model back to its `.specsync/specs/` JSON file. */ +export function saveSpec(view: SpecViewModel): string { + const workspaceRoot = workspaceRootFor(view.filePath) + || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + throw new Error("No workspace folder found"); + } + + const storePath = + view.storePath || + path.join(workspaceRoot, buildSpecStorePath(view.filePath, view.functionName)); + + const dir = path.dirname(storePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + let existing: Partial = {}; + if (fs.existsSync(storePath)) { + try { + existing = JSON.parse(fs.readFileSync(storePath, "utf8")) as StoredSpec; + } catch { + existing = {}; + } + } + + const stored: StoredSpec = { + version: typeof existing.version === "number" ? existing.version : 1, + functionName: view.functionName, + filePath: view.filePath, + lineNumber: existing.lineNumber, + acceptedAt: existing.acceptedAt || new Date().toISOString(), + pr: existing.pr, + sha: existing.sha, + confidence: normalizeConfidence(view.confidence, 0), + contract: { + preconditions: view.preconditions, + postconditions: view.postconditions, + invariants: view.invariants, + edgeCases: view.edgeCases, + reasoning: view.reasoning, + }, + implementationHash: existing.implementationHash, + }; + + fs.writeFileSync(storePath, JSON.stringify(stored, null, 2) + "\n", "utf8"); + return storePath; +} + +/** Append a clause to an existing or new spec file for a function. */ +export function appendClauseToSpec( + workspaceRoot: string, + sourceRelPath: string, + functionName: string, + type: "precondition" | "postcondition" | "invariant", + label: string +): string { + const storeRel = buildSpecStorePath(sourceRelPath, functionName); + const storePath = path.join(workspaceRoot, storeRel); + const existing = getSpecForFunction(functionName, path.join(workspaceRoot, sourceRelPath), { + respectThreshold: false, + }); + + const view: SpecViewModel = existing || { + functionName, + filePath: sourceRelPath, + confidence: 0.5, + preconditions: [], + postconditions: [], + invariants: [], + edgeCases: [], + reasoning: "", + storePath, + }; + + if (type === "precondition") { + view.preconditions = [...view.preconditions, label]; + } else if (type === "postcondition") { + view.postconditions = [...view.postconditions, label]; + } else { + view.invariants = [...view.invariants, label]; + } + + view.storePath = storePath; + return saveSpec(view); +} diff --git a/vscode-extension/src/suggestion-panel.ts b/vscode-extension/src/suggestion-panel.ts index c83ae1a..4a1fe64 100644 --- a/vscode-extension/src/suggestion-panel.ts +++ b/vscode-extension/src/suggestion-panel.ts @@ -1,202 +1,155 @@ -import * as vscode from 'vscode'; - -export function activate(context: vscode.ExtensionContext) { - const suggestionProvider = new SpecSuggestionProvider(); - - context.subscriptions.push( - vscode.window.registerTreeDataProvider('specsync-suggestion-list', suggestionProvider), - vscode.commands.registerCommand('specsync.addSuggestion', addSuggestion) - ); - - // Listen for text changes - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument(handleTextChange) - ); -} +import * as path from "path"; +import * as vscode from "vscode"; +import { appendClauseToSpec, normalizeConfidence, relativeSourcePath } from "./spec-reader"; -class SpecSuggestionProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - - private suggestions: SpecSuggestion[] = []; - - getTreeItem(element: SpecSuggestion): vscode.TreeItem { - return element; - } - - getChildren(element?: SpecSuggestion): Thenable { - if (element) { - return Promise.resolve([]); - } - return Promise.resolve(this.suggestions); - } - - updateSuggestions(newSuggestions: SpecSuggestion[]) { - this.suggestions = newSuggestions; - this._onDidChangeTreeData.fire(undefined); - } -} +export type SuggestionKind = "precondition" | "postcondition" | "invariant"; -class SpecSuggestion extends vscode.TreeItem { +export class SpecSuggestionItem extends vscode.TreeItem { constructor( public readonly label: string, public readonly confidence: number, public readonly reasoning: string, - public readonly type: 'precondition' | 'postcondition' | 'invariant' + public readonly type: SuggestionKind ) { super(label, vscode.TreeItemCollapsibleState.None); - - this.tooltip = `Confidence: ${confidence}%\nReasoning: ${reasoning}`; - this.description = `${confidence}% confidence`; - this.contextValue = 'specSuggestion'; - + + const pct = Math.round(normalizeConfidence(confidence, 0) * 100); + this.tooltip = `Confidence: ${pct}%\nReasoning: ${reasoning}`; + this.description = `${pct}% confidence`; + this.contextValue = "specSuggestion"; + this.iconPath = new vscode.ThemeIcon("lightbulb"); this.command = { - command: 'specsync.addSuggestion', - title: 'Add Suggestion', - arguments: [this] + command: "specsync.addSuggestion", + title: "Add Suggestion", + arguments: [this], }; } - - iconPath = new vscode.ThemeIcon('lightbulb'); } -async function handleTextChange(event: vscode.TextDocumentChangeEvent) { - const document = event.document; - const changes = event.contentChanges; - - for (const change of changes) { - const text = change.text; - const position = change.range.start; - - // Analyze the change for potential spec suggestions - const suggestions = await analyzeTextChange(text, position, document); - - if (suggestions.length > 0) { - // Update the suggestion panel - const provider = vscode.workspace.getConfiguration('specsync').get('suggestionProvider'); - if (provider) { - provider.updateSuggestions(suggestions); - } +export class SpecSuggestionProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData = new vscode.EventEmitter< + SpecSuggestionItem | undefined | null | void + >(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private suggestions: SpecSuggestionItem[] = []; + + getTreeItem(element: SpecSuggestionItem): vscode.TreeItem { + return element; + } + + getChildren(element?: SpecSuggestionItem): Thenable { + if (element) { + return Promise.resolve([]); } + return Promise.resolve(this.suggestions); + } + + updateSuggestions(newSuggestions: SpecSuggestionItem[]): void { + this.suggestions = newSuggestions; + this._onDidChangeTreeData.fire(); + } + + clear(): void { + this.suggestions = []; + this._onDidChangeTreeData.fire(); } } -async function analyzeTextChange(text: string, position: vscode.Position, document: vscode.TextDocument): Promise { - const suggestions: SpecSuggestion[] = []; - - // Simple pattern matching for common cases - if (text.includes('balance -= amount')) { - suggestions.push(new SpecSuggestion( - 'Ensure balance ≥ 0', - 85, - 'Detected balance reduction - should maintain non-negative balance', - 'invariant' - )); +/** Heuristic local suggestions (confidence always 0–1). */ +export function analyzeTextChange( + text: string, + _position: vscode.Position, + _document: vscode.TextDocument +): SpecSuggestionItem[] { + const suggestions: SpecSuggestionItem[] = []; + + if (text.includes("balance -= amount")) { + suggestions.push( + new SpecSuggestionItem( + "Ensure balance ≥ 0", + 0.85, + "Detected balance reduction — should maintain non-negative balance", + "invariant" + ) + ); } - - if (text.includes('return result')) { - suggestions.push(new SpecSuggestion( - 'Result is not null', - 90, - 'Function returns a value - should ensure it's not null', - 'postcondition' - )); + + if (text.includes("return result")) { + suggestions.push( + new SpecSuggestionItem( + "Result is not null", + 0.9, + "Function returns a value — should ensure it is not null", + "postcondition" + ) + ); } - - if (text.includes('if (input')) { - suggestions.push(new SpecSuggestion( - 'Input is validated', - 80, - 'Detected input validation - should document precondition', - 'precondition' - )); + + if (text.includes("if (input")) { + suggestions.push( + new SpecSuggestionItem( + "Input is validated", + 0.8, + "Detected input validation — should document precondition", + "precondition" + ) + ); } - + return suggestions; } -async function addSuggestion(suggestion: SpecSuggestion) { +function getFunctionNearPosition( + document: vscode.TextDocument, + position: vscode.Position +): string | null { + for (let line = position.line; line >= Math.max(0, position.line - 40); line--) { + const match = document.lineAt(line).text.match( + /(?:(?:export\s+)?(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\()/ + ); + if (match) { + return match[1] || match[2] || null; + } + } + return null; +} + +export async function addSuggestionToActiveEditor( + suggestion: SpecSuggestionItem +): Promise { const editor = vscode.window.activeTextEditor; if (!editor) { - vscode.window.showErrorMessage('No active editor'); + vscode.window.showErrorMessage("No active editor"); return; } - - // Find the function at current position - const position = editor.selection.active; - const functionName = getFunctionAtPosition(editor.document, position); - - if (!functionName) { - vscode.window.showErrorMessage('No function found at current position'); + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri); + if (!workspaceFolder) { + vscode.window.showErrorMessage("No workspace folder for active editor"); return; } - - // Add the suggestion to the function's spec - await addSuggestionToFunction(functionName, suggestion); - - vscode.window.showInformationMessage(`Added ${suggestion.type}: ${suggestion.label}`); -} -function getFunctionAtPosition(document: vscode.TextDocument, position: vscode.Position): string | null { - const line = document.lineAt(position.line); - const functionMatch = line.text.match(/function\s+(\w+)\s*\(/); - return functionMatch ? functionMatch[1] : null; -} + const functionName = getFunctionNearPosition(editor.document, editor.selection.active); + if (!functionName) { + vscode.window.showErrorMessage("No function found near the cursor"); + return; + } -async function addSuggestionToFunction(functionName: string, suggestion: SpecSuggestion) { - // This would integrate with the spec storage system - const config = vscode.workspace.getConfiguration('specsync'); - const cachePath = config.get('specCachePath', '.specsync/spec.json'); - + const rel = relativeSourcePath(workspaceFolder.uri.fsPath, editor.document.fileName); try { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) return; - - const fullCachePath = path.join(workspaceFolder.uri.fsPath, cachePath); - let cache = {}; - - if (fs.existsSync(fullCachePath)) { - const cacheContent = fs.readFileSync(fullCachePath, 'utf8'); - cache = JSON.parse(cacheContent); - } - - const key = `${workspaceFolder.name}:${functionName}`; - if (!cache[key]) { - cache[key] = { - functionName, - preconditions: [], - postconditions: [], - invariants: [], - confidence: 0, - reasoning: '' - }; - } - - // Add the suggestion to the appropriate array - switch (suggestion.type) { - case 'precondition': - cache[key].preconditions.push(suggestion.label); - break; - case 'postcondition': - cache[key].postconditions.push(suggestion.label); - break; - case 'invariant': - cache[key].invariants.push(suggestion.label); - break; - } - - // Ensure directory exists - const dir = path.dirname(fullCachePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync(fullCachePath, JSON.stringify(cache, null, 2)); - + const saved = appendClauseToSpec( + workspaceFolder.uri.fsPath, + rel, + functionName, + suggestion.type, + suggestion.label + ); + vscode.window.showInformationMessage( + `Added ${suggestion.type} to ${functionName} (${path.basename(saved)})` + ); } catch (error) { - console.error('Error saving suggestion:', error); - vscode.window.showErrorMessage(`Failed to save suggestion: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to save suggestion: ${message}`); } } - -export function deactivate() {} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json index 3d2f8f6..3d50e6e 100644 --- a/vscode-extension/tsconfig.json +++ b/vscode-extension/tsconfig.json @@ -3,15 +3,15 @@ "module": "commonjs", "target": "ES2020", "outDir": "out", - "lib": [ - "ES2020" - ], + "lib": ["ES2020"], "sourceMap": true, "rootDir": "src", - "strict": true + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node", "vscode"] }, - "exclude": [ - "node_modules", - ".vscode-test" - ] + "include": ["src/**/*"], + "exclude": ["node_modules", ".vscode-test", "out"] }