diff --git a/README.md b/README.md index ff6cc8d..d4415c9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ OpenQC currently wires **17 bundled LSP integrations** for computational chemist ### Latest LSP Status -OpenQC tracks latest LSP support by the upstream default branch recorded in `src/lsp/registry.ts`. Run `npm run lsp:check-latest` before release or PR handoff to compare local sibling checkouts with the configured remote branch heads. If the main `cp2k-lsp-enhanced` checkout has unrelated local work, OpenQC can use `.worktrees-lsp-latest/cp2k-lsp-enhanced` for the latest CP2K LSP without overwriting that checkout. +OpenQC tracks latest LSP support by the upstream default branch recorded in `src/lsp/registry.ts`. Run `npm run lsp:check-latest` before release or PR handoff to compare isolated source checkouts with configured remote branch heads. Run `npm run lsp:check-installed -- --strict` on a release host to additionally require the managed `.lsp-latest` runtime, server executable, agent CLI, source commit, manifest version, release-matrix version, and installed commit to agree. The installed-fleet check can emit the machine-readable `openqc.lsp.runtime-ledger.v1` report with `--json` or `--report-path `. ### LSP Alignment Matrix @@ -286,6 +286,13 @@ missing package return a structured JSON-RPC error without terminating the serve ## Marketplace Release Checklist +- Confirm `package.json` version, publisher, display name, icon, and keywords. +- Run the TypeScript build and extension packaging command from a clean checkout. +- Run `npm run lsp:check-latest` and keep every bundled LSP at the configured remote branch head or an isolated latest worktree. +- Run `npm run lsp:check-installed -- --strict` after rebuilding `.lsp-latest`; a latest source checkout does not prove the executable on `PATH` came from that commit. +- Smoke test each bundled LSP integration listed in `docs/LSP_COMPATIBILITY.md`. +- Capture or refresh screenshots for syntax highlighting, diagnostics, 3D visualization, and validation. +- Publish release notes that list supported formats, known parser gaps, VS Code version tested, and LSP versions or commit SHAs. - Use Node 22 and the locked dependencies: `nvm use && npm ci`. - Run `make format lint typecheck test check`; `npm run check:release` additionally creates and verifies `vsix/openqc-0.0.1.vsix`, its SHA-256 file, and CycloneDX SBOM. - Create `v0.0.1` only from a clean commit exactly synchronized with `origin/master`; `npm run release:tag` enforces that boundary. diff --git a/docs/LSP_COMPATIBILITY.md b/docs/LSP_COMPATIBILITY.md index 41a2c29..9661115 100644 --- a/docs/LSP_COMPATIBILITY.md +++ b/docs/LSP_COMPATIBILITY.md @@ -10,30 +10,30 @@ Regenerate it with `npm run lsp:generate-compatibility-doc` after registry, back | Backends | 17 | | Passing | 17 | | Blocking gaps | 0 | -| Warnings | 16 | -| Graduation score | 80/100 | +| Warnings | 0 | +| Graduation score | 100/100 | ## Backend Matrix | Backend | Language | Branch | File Types | Registry Stability | Gate Maturity | Release Evidence | Manifest | Provenance | Fixtures | Smoke | Diagnostics | Traceability | |---------|----------|--------|------------|--------------------|---------------|------------------|----------|------------|----------|-------|-------------|--------------| -| [newtontech/abacus-lsp](https://github.com/newtontech/abacus-lsp) | `abacus` | `main` | `INPUT`, `STRU`, `KPT` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `1e5d05f91b3f` | pass | pass | pass | pass | pass | missing | -| [newtontech/abinit-lsp](https://github.com/newtontech/abinit-lsp) | `abinit` | `main` | `.abi`, `.abinit` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `7612aeda243b` | pass | pass | pass | pass | pass | missing | -| [newtontech/cif-lsp](https://github.com/newtontech/cif-lsp) | `cif` | `master` | `.cif` | experimental | stable | `v1.0.2`
HEAD `b3784d69cece` | pass | pass | pass | pass | pass | missing | -| [newtontech/cp2k-lsp-enhanced](https://github.com/newtontech/cp2k-lsp-enhanced) | `cp2k` | `develop` | `.inp` | experimental | stable | `v0.9.1`
HEAD `c38db0a58344` | pass | pass | pass | pass | pass | missing | -| [newtontech/VASP-LSP](https://github.com/newtontech/VASP-LSP) | `vasp` | `main` | `INCAR`, `POSCAR`, `KPOINTS`, `POTCAR`, `CONTCAR`, `OSZICAR`, `OUTCAR`, `vasprun.xml` | stable | stable | `v0.4.4`
VERSION `0.4.4`
HEAD `587fd094f7a7` | pass | pass | pass | pass | pass | missing | -| [newtontech/gaussian-lsp](https://github.com/newtontech/gaussian-lsp) | `gaussian` | `main` | `.gjf`, `.com` | stable | stable | `v0.2.11`
VERSION `0.2.11`
HEAD `e886a6553cd9` | pass | pass | pass | pass | pass | missing | -| [newtontech/orca-lsp](https://github.com/newtontech/orca-lsp) | `orca` | `main` | `.inp` | stable | stable | `v0.5.5`
VERSION `0.5.5`
HEAD `ecd4cabdba75` | pass | pass | pass | pass | pass | missing | -| [newtontech/qe-lsp](https://github.com/newtontech/qe-lsp) | `qe` | `main` | `.in`, `.pw.in`, `.relax.in`, `.vc-relax.in`, `.scf.in`, `.nscf.in`, `.bands.in`, `.ph.in`, `.dos.in` | stable | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `ab0c4ac6cee1` | pass | pass | pass | pass | pass | missing | -| [newtontech/gamess-lsp](https://github.com/newtontech/gamess-lsp) | `gamess` | `main` | `.inp` | stable | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `1dd85afa3d85` | pass | pass | pass | pass | pass | missing | -| [newtontech/nwchem-lsp](https://github.com/newtontech/nwchem-lsp) | `nwchem` | `main` | `.nw`, `.nwinp` | experimental | stable | `v0.3.0`
HEAD `a9109413251e` | pass | pass | pass | pass | pass | missing | -| [newtontech/gpumd-lsp](https://github.com/newtontech/gpumd-lsp) | `gpumd` | `main` | `run.in`, `nep.in` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `882a582a23dd` | pass | pass | pass | pass | pass | missing | -| [newtontech/gromacs-lsp](https://github.com/newtontech/gromacs-lsp) | `gromacs` | `main` | `.top`, `.itp`, `.mdp`, `.gro` | experimental | stable | `v0.0.3`
VERSION `0.0.3`
HEAD `3074ac19d5a7` | pass | pass | pass | pass | pass | missing | -| [newtontech/lammps-lsp](https://github.com/newtontech/lammps-lsp) | `lammps` | `master` | `.lmp`, `.lammps`, `.lmps`, `in.lammps` | experimental | stable | `0.1.0-pre-release-3`
HEAD `8d77f45d8229` | pass | pass | pass | pass | pass | missing | -| [newtontech/mlip-lsp](https://github.com/newtontech/mlip-lsp) | `mlip` | `main` | `.mlip.json`, `.mlip.yaml`, `.mlip.yml`, `mlip.json`, `mlip.yaml`, `mlip.yml` | experimental | stable | `v0.2.0`
VERSION `0.2.0`
HEAD `74599859d2da` | pass | pass | pass | pass | pass | missing | -| [newtontech/pyatb-lsp](https://github.com/newtontech/pyatb-lsp) | `pyatb` | `main` | `.pyatb.py`, `run_pyatb.py` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `beacd3141616` | pass | pass | pass | pass | pass | missing | -| [newtontech/pyscf-lsp](https://github.com/newtontech/pyscf-lsp) | `pyscf` | `main` | `.pyscf.py`, `run_pyscf.py` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `766cf71b9e20` | pass | pass | pass | pass | pass | missing | -| [newtontech/dpgen-lsp](https://github.com/newtontech/dpgen-lsp) | `dpgen` | `main` | `param.json`, `machine.json` | experimental | stable | `v0.0.1`
VERSION `0.1.0`
HEAD `91380c94419b` | pass | pass | pass | pass | pass | pass | +| [newtontech/abacus-lsp](https://github.com/newtontech/abacus-lsp) | `abacus` | `main` | `INPUT`, `STRU`, `KPT` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `71e0ead0463d` | pass | pass | pass | pass | pass | pass | +| [newtontech/abinit-lsp](https://github.com/newtontech/abinit-lsp) | `abinit` | `main` | `.abi`, `.abinit` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `b1c3124534ec` | pass | pass | pass | pass | pass | pass | +| [newtontech/cif-lsp](https://github.com/newtontech/cif-lsp) | `cif` | `master` | `.cif` | experimental | stable | `v1.0.2`
HEAD `314942468eff` | pass | pass | pass | pass | pass | pass | +| [newtontech/cp2k-lsp-enhanced](https://github.com/newtontech/cp2k-lsp-enhanced) | `cp2k` | `develop` | `.inp` | experimental | stable | `v0.9.1`
HEAD `7661a8c277fa` | pass | pass | pass | pass | pass | pass | +| [newtontech/VASP-LSP](https://github.com/newtontech/VASP-LSP) | `vasp` | `main` | `INCAR`, `POSCAR`, `KPOINTS`, `POTCAR`, `CONTCAR`, `OSZICAR`, `OUTCAR`, `vasprun.xml` | stable | stable | `v0.4.4`
VERSION `0.4.4`
HEAD `be948e700924` | pass | pass | pass | pass | pass | pass | +| [newtontech/gaussian-lsp](https://github.com/newtontech/gaussian-lsp) | `gaussian` | `main` | `.gjf`, `.com` | stable | stable | `v0.2.11`
VERSION `0.2.11`
HEAD `282fb98ead89` | pass | pass | pass | pass | pass | pass | +| [newtontech/orca-lsp](https://github.com/newtontech/orca-lsp) | `orca` | `main` | `.inp` | stable | stable | `v0.5.5`
VERSION `0.5.5`
HEAD `7e7fa2f5d7ca` | pass | pass | pass | pass | pass | pass | +| [newtontech/qe-lsp](https://github.com/newtontech/qe-lsp) | `qe` | `main` | `.in`, `.pw.in`, `.relax.in`, `.vc-relax.in`, `.scf.in`, `.nscf.in`, `.bands.in`, `.ph.in`, `.dos.in` | stable | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `c54045ae3b42` | pass | pass | pass | pass | pass | pass | +| [newtontech/gamess-lsp](https://github.com/newtontech/gamess-lsp) | `gamess` | `main` | `.inp` | stable | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `289f1ac125fd` | pass | pass | pass | pass | pass | pass | +| [newtontech/nwchem-lsp](https://github.com/newtontech/nwchem-lsp) | `nwchem` | `main` | `.nw`, `.nwinp` | experimental | stable | `v0.3.0`
HEAD `cecc5ac62fb2` | pass | pass | pass | pass | pass | pass | +| [newtontech/gpumd-lsp](https://github.com/newtontech/gpumd-lsp) | `gpumd` | `main` | `run.in`, `nep.in` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `6c5d623c9d79` | pass | pass | pass | pass | pass | pass | +| [newtontech/gromacs-lsp](https://github.com/newtontech/gromacs-lsp) | `gromacs` | `main` | `.top`, `.itp`, `.mdp`, `.gro` | experimental | stable | `v0.0.3`
VERSION `0.0.3`
HEAD `61623cc06e71` | pass | pass | pass | pass | pass | pass | +| [newtontech/lammps-lsp](https://github.com/newtontech/lammps-lsp) | `lammps` | `master` | `.lmp`, `.lammps`, `.lmps`, `in.lammps` | experimental | stable | `0.1.0-pre-release-3`
HEAD `6f085a20b8f7` | pass | pass | pass | pass | pass | pass | +| [newtontech/mlip-lsp](https://github.com/newtontech/mlip-lsp) | `mlip` | `main` | `.mlip.json`, `.mlip.yaml`, `.mlip.yml`, `mlip.json`, `mlip.yaml`, `mlip.yml` | experimental | stable | `v0.2.0`
VERSION `0.2.0`
HEAD `d428ba5fbbda` | pass | pass | pass | pass | pass | pass | +| [newtontech/pyatb-lsp](https://github.com/newtontech/pyatb-lsp) | `pyatb` | `main` | `.pyatb.py`, `run_pyatb.py` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `b100e3ef8f12` | pass | pass | pass | pass | pass | pass | +| [newtontech/pyscf-lsp](https://github.com/newtontech/pyscf-lsp) | `pyscf` | `main` | `.pyscf.py`, `run_pyscf.py` | experimental | stable | `v0.1.0`
VERSION `0.1.0`
HEAD `fe377735407b` | pass | pass | pass | pass | pass | pass | +| [newtontech/dpgen-lsp](https://github.com/newtontech/dpgen-lsp) | `dpgen` | `main` | `param.json`, `machine.json` | experimental | stable | `v0.0.1`
VERSION `0.1.0`
HEAD `436419c1ced1` | pass | pass | pass | pass | pass | pass | ## Agent CLI Readiness @@ -59,54 +59,7 @@ Regenerate it with `npm run lsp:generate-compatibility-doc` after registry, back ## Actionable Gate Gaps -### abacus-lsp -- WARN: No docstring/wiki/raw traceability report found - -### abinit-lsp -- WARN: No docstring/wiki/raw traceability report found - -### cif-lsp -- WARN: No docstring/wiki/raw traceability report found - -### cp2k-lsp-enhanced -- WARN: No docstring/wiki/raw traceability report found - -### vasp-lsp -- WARN: No docstring/wiki/raw traceability report found - -### gaussian-lsp -- WARN: No docstring/wiki/raw traceability report found - -### orca-lsp -- WARN: No docstring/wiki/raw traceability report found - -### qe-lsp -- WARN: No docstring/wiki/raw traceability report found - -### gamess-lsp -- WARN: No docstring/wiki/raw traceability report found - -### nwchem-lsp -- WARN: No docstring/wiki/raw traceability report found - -### gpumd-lsp -- WARN: No docstring/wiki/raw traceability report found - -### gromacs-lsp -- WARN: No docstring/wiki/raw traceability report found - -### lammps-lsp -- WARN: No docstring/wiki/raw traceability report found - -### mlip-lsp -- WARN: No docstring/wiki/raw traceability report found - -### pyatb-lsp -- WARN: No docstring/wiki/raw traceability report found - -### pyscf-lsp -- WARN: No docstring/wiki/raw traceability report found - +No blocking or warning gaps are reported by `scripts/check-lsp-family.mjs`. ## OpenQC Integration Guarantees diff --git a/package.json b/package.json index 7953213..e80a0eb 100644 --- a/package.json +++ b/package.json @@ -1723,6 +1723,7 @@ "watch": "tsc -watch -p ./", "lsp:check-latest": "node scripts/check-lsp-latest.mjs", "lsp:check-family": "node scripts/check-lsp-family.mjs", + "lsp:check-installed": "node scripts/check-lsp-installed.mjs", "lsp:check-bohrium-registry": "node scripts/check-bohrium-registry-alignment.mjs --strict", "lsp:generate-compatibility-doc": "node scripts/generate-lsp-compatibility-doc.mjs", "pretest": "npm run compile && npm run lint", diff --git a/scripts/check-bohrium-registry-alignment.mjs b/scripts/check-bohrium-registry-alignment.mjs index 1e6fe78..02ce957 100644 --- a/scripts/check-bohrium-registry-alignment.mjs +++ b/scripts/check-bohrium-registry-alignment.mjs @@ -17,12 +17,21 @@ */ import { existsSync, readFileSync } from 'fs'; -import { dirname, join, resolve } from 'path'; +import { basename, dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); -const codeRoot = resolve(repoRoot, '..'); +const codeRoot = resolveCodeRoot(repoRoot); + +function resolveCodeRoot(root) { + const parent = resolve(root, '..'); + if (basename(parent) !== '.worktrees') return parent; + const worktreeContainer = resolve(parent, '..'); + return existsSync(join(worktreeContainer, '.git')) + ? resolve(worktreeContainer, '..') + : worktreeContainer; +} const jsonMode = process.argv.includes('--json'); const strictMode = process.argv.includes('--strict'); diff --git a/scripts/check-lsp-family.mjs b/scripts/check-lsp-family.mjs index a332104..3aff0e2 100644 --- a/scripts/check-lsp-family.mjs +++ b/scripts/check-lsp-family.mjs @@ -615,7 +615,11 @@ function git(args, options = {}) { function resolveCodeRoot(root) { const parent = resolve(root, '..'); - return basename(parent) === '.worktrees' ? resolve(parent, '..') : parent; + if (basename(parent) !== '.worktrees') return parent; + const worktreeContainer = resolve(parent, '..'); + return existsSync(join(worktreeContainer, '.git')) + ? resolve(worktreeContainer, '..') + : worktreeContainer; } function findLocalCheckout(entry) { diff --git a/scripts/check-lsp-installed.mjs b/scripts/check-lsp-installed.mjs new file mode 100644 index 0000000..055cf38 --- /dev/null +++ b/scripts/check-lsp-installed.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); +const codeRoot = process.env.OPENQC_LSP_CODE_ROOT + ? resolve(process.env.OPENQC_LSP_CODE_ROOT) + : resolveCodeRoot(repoRoot); +const runtimeRoot = process.env.OPENQC_LSP_RUNTIME_ROOT + ? resolve(process.env.OPENQC_LSP_RUNTIME_ROOT) + : join(codeRoot, '.lsp-latest'); +const registryPath = process.env.OPENQC_LSP_REGISTRY_PATH + ? resolve(process.env.OPENQC_LSP_REGISTRY_PATH) + : join(repoRoot, 'src/lsp/registry.ts'); +const matrixPath = process.env.OPENQC_LSP_RELEASE_MATRIX_PATH + ? resolve(process.env.OPENQC_LSP_RELEASE_MATRIX_PATH) + : join(scriptDir, 'lsp-release-matrix.json'); +const strict = process.argv.includes('--strict') || process.argv.includes('--fail-on-drift'); +const jsonMode = process.argv.includes('--json'); +const reportPath = process.argv.find((arg, index) => process.argv[index - 1] === '--report-path'); + +function resolveCodeRoot(root) { + const parent = resolve(root, '..'); + if (basename(parent) !== '.worktrees') return parent; + const worktreeContainer = resolve(parent, '..'); + return existsSync(join(worktreeContainer, '.git')) + ? resolve(worktreeContainer, '..') + : worktreeContainer; +} + +function gitHead(path) { + if (!existsSync(path)) return ''; + try { + return execFileSync('git', ['-C', path, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function headTag(path) { + if (!existsSync(path)) return ''; + try { + return ( + execFileSync('git', ['-C', path, 'tag', '--points-at', 'HEAD', '--sort=-version:refname'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .trim() + .split('\n') + .filter(Boolean)[0] ?? '' + ); + } catch { + return ''; + } +} + +function manifestVersion(path) { + const packageJson = join(path, 'package.json'); + if (existsSync(packageJson)) { + try { + return JSON.parse(readFileSync(packageJson, 'utf8')).version ?? ''; + } catch { + return ''; + } + } + + for (const name of ['pyproject.toml', 'Cargo.toml']) { + const manifest = join(path, name); + if (!existsSync(manifest)) continue; + const content = readFileSync(manifest, 'utf8'); + const projectSection = + content.match(/\[(?:project|package)\]([\s\S]*?)(?=\n\[|$)/)?.[1] ?? content; + const match = projectSection.match(/^version\s*=\s*["']([^"']+)["']/m); + if (match) return match[1]; + } + + const versionFile = join(path, 'VERSION'); + return existsSync(versionFile) ? readFileSync(versionFile, 'utf8').trim() : ''; +} + +function sourceCheckout(repoName) { + const candidates = [join(codeRoot, '.worktrees-lsp-latest', repoName), join(codeRoot, repoName)]; + return candidates.find(candidate => gitHead(candidate)) ?? candidates[0]; +} + +function executablePaths(entry, agentCli) { + const repoName = entry.repository.split('/')[1]; + const installedCheckout = join(runtimeRoot, repoName); + if (entry.id === 'cif-lsp') { + return { + server: join(installedCheckout, 'server', 'out', 'server.js'), + agent: join(installedCheckout, 'server', 'out', 'cifLspTool.js'), + }; + } + if (entry.id === 'lammps-lsp') { + return { + server: join(installedCheckout, 'target', 'release', entry.executable), + agent: join(installedCheckout, 'target', 'release', agentCli), + }; + } + return { + server: join(runtimeRoot, '.venv', 'bin', entry.executable), + agent: join(runtimeRoot, '.venv', 'bin', agentCli), + }; +} + +const registry = readFileSync(registryPath, 'utf8'); +const entries = [ + ...registry.matchAll( + /id: '([^']+)'[\s\S]*?repository: '([^']+)'[\s\S]*?executable: '([^']+)'[\s\S]*?languageId: '([^']+)'[\s\S]*?defaultBranch: '([^']+)'/g + ), +].map(match => ({ + id: match[1], + repository: match[2], + executable: match[3], + languageId: match[4], + defaultBranch: match[5], +})); +const readiness = new Map( + [...registry.matchAll(/'([^']+)': diagnosticReadiness\('([^']+)',/g)].map(match => [ + match[1], + match[2], + ]) +); +const matrix = JSON.parse(readFileSync(matrixPath, 'utf8')); + +if (entries.length !== 17) { + throw new Error(`Expected 17 bundled LSP entries, found ${entries.length}`); +} +if (matrix.schemaVersion !== 'openqc.lsp.release-matrix.v1') { + throw new Error(`Unsupported release matrix schema: ${matrix.schemaVersion}`); +} + +const rows = entries.map(entry => { + const repoName = entry.repository.split('/')[1]; + const sourcePath = sourceCheckout(repoName); + const installedPath = join(runtimeRoot, repoName); + const sourceCommit = gitHead(sourcePath); + const installedCommit = gitHead(installedPath); + const tagVersion = headTag(sourcePath).replace(/^v/, ''); + const sourceVersion = manifestVersion(sourcePath); + const installedVersion = manifestVersion(installedPath); + const release = matrix.entries[entry.id]; + const agentCli = readiness.get(entry.id) ?? ''; + const executables = executablePaths(entry, agentCli); + const problems = []; + + if (!release) problems.push('missing-release-matrix-entry'); + if (!sourceCommit) problems.push('missing-source-checkout'); + if (!installedCommit) problems.push('missing-installed-checkout'); + if (sourceCommit && installedCommit && sourceCommit !== installedCommit) { + problems.push('installed-commit-drift'); + } + if (!existsSync(executables.server)) problems.push('missing-server-executable'); + if (!agentCli || !existsSync(executables.agent)) problems.push('missing-agent-cli'); + if (release?.targetVersion && installedVersion && release.targetVersion !== installedVersion) { + problems.push('installed-version-drift'); + } + + return { + id: entry.id, + repository: entry.repository, + languageId: entry.languageId, + defaultBranch: entry.defaultBranch, + package: release?.package ?? '', + channel: release?.channel ?? '', + sourcePath, + installedPath, + sourceCommit, + installedCommit, + tagVersion, + manifestVersion: sourceVersion, + installedVersion, + registryVersion: release?.targetVersion ?? '', + serverExecutable: executables.server, + agentExecutable: executables.agent, + runtimeParity: problems.length === 0 ? 'pass' : 'fail', + problems, + }; +}); + +const report = { + schemaVersion: 'openqc.lsp.runtime-ledger.v1', + generatedAt: new Date().toISOString(), + codeRoot, + runtimeRoot, + summary: { + total: rows.length, + passing: rows.filter(row => row.runtimeParity === 'pass').length, + failing: rows.filter(row => row.runtimeParity === 'fail').length, + }, + entries: rows, +}; + +if (reportPath) { + writeFileSync(resolve(reportPath), `${JSON.stringify(report, null, 2)}\n`); +} + +if (jsonMode) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} else { + console.table( + rows.map(row => ({ + id: row.id, + source: row.sourceCommit.slice(0, 12) || '-', + installed: row.installedCommit.slice(0, 12) || '-', + manifest: row.manifestVersion || '-', + installedVersion: row.installedVersion || '-', + target: row.registryVersion || '-', + parity: row.runtimeParity, + problems: row.problems.join(',') || '-', + })) + ); + console.log( + `\nInstalled LSP runtime parity: ${report.summary.passing}/${report.summary.total} passing, ${report.summary.failing} failing.` + ); +} + +if (strict && report.summary.failing > 0) { + process.exitCode = 1; +} diff --git a/scripts/check-lsp-latest.mjs b/scripts/check-lsp-latest.mjs index 994ddae..337a540 100644 --- a/scripts/check-lsp-latest.mjs +++ b/scripts/check-lsp-latest.mjs @@ -8,29 +8,36 @@ import { readFileSync } from 'fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); -const codeRoot = resolveCodeRoot(repoRoot); -const registryPath = join(repoRoot, 'src/lsp/registry.ts'); +const codeRoot = process.env.OPENQC_LSP_CODE_ROOT + ? resolve(process.env.OPENQC_LSP_CODE_ROOT) + : resolveCodeRoot(repoRoot); +const registryPath = process.env.OPENQC_LSP_REGISTRY_PATH + ? resolve(process.env.OPENQC_LSP_REGISTRY_PATH) + : join(repoRoot, 'src/lsp/registry.ts'); const failOnDrift = process.argv.includes('--fail-on-drift'); +let githubUnavailableError = ''; const registry = readFileSync(registryPath, 'utf8'); -const entries = [...registry.matchAll( - /id: '([^']+)'[\s\S]*?repository: '([^']+)'[\s\S]*?languageId: '([^']+)'[\s\S]*?defaultBranch: '([^']+)'/g -)].map(match => ({ +const entries = [ + ...registry.matchAll( + /id: '([^']+)'[\s\S]*?repository: '([^']+)'[\s\S]*?languageId: '([^']+)'[\s\S]*?defaultBranch: '([^']+)'/g + ), +].map(match => ({ id: match[1], repository: match[2], languageId: match[3], defaultBranch: match[4], })); -const readiness = new Map([...registry.matchAll( - /'([^']+)': diagnosticReadiness\('([^']+)', '([^']+)'/g -)].map(match => [ - match[1], - { - agentCli: match[2], - closedLoop: match[3], - }, -])); +const readiness = new Map( + [...registry.matchAll(/'([^']+)': diagnosticReadiness\('([^']+)', '([^']+)'/g)].map(match => [ + match[1], + { + agentCli: match[2], + closedLoop: match[3], + }, + ]) +); if (entries.length === 0) { throw new Error(`No LSP registry entries found in ${registryPath}`); @@ -46,13 +53,90 @@ function git(args, options = {}) { function resolveCodeRoot(root) { const parent = resolve(root, '..'); - return basename(parent) === '.worktrees' ? resolve(parent, '..') : parent; + if (basename(parent) !== '.worktrees') return parent; + const worktreeContainer = resolve(parent, '..'); + return existsSync(join(worktreeContainer, '.git')) + ? resolve(worktreeContainer, '..') + : worktreeContainer; } function remoteHead(entry) { - const url = `https://github.com/${entry.repository}.git`; - const output = git(['ls-remote', url, `refs/heads/${entry.defaultBranch}`]); - return output.split(/\s+/)[0] || ''; + if (githubUnavailableError) { + return { head: '', error: githubUnavailableError }; + } + + const ref = `refs/heads/${entry.defaultBranch}`; + const httpsUrl = `https://github.com/${entry.repository}.git`; + const sshUrl = `git@github.com:${entry.repository}.git`; + + const https = probeRemoteHead(httpsUrl, ref); + if (https.head || !isRemoteProbeUnavailable(https.errorObject, https.error)) { + return { head: https.head, error: https.error }; + } + + const ssh = probeRemoteHead(sshUrl, ref); + if (ssh.head || !isRemoteProbeUnavailable(ssh.errorObject, ssh.error)) { + return { head: ssh.head, error: ssh.error }; + } + + const message = `HTTPS: ${https.error}; SSH: ${ssh.error}`; + githubUnavailableError = message; + return { head: '', error: message }; +} + +function probeRemoteHead(url, ref) { + try { + const output = git(['ls-remote', url, ref], { timeout: 30000 }); + return { head: output.split(/\s+/)[0] || '', error: '', errorObject: null }; + } catch (error) { + return { + head: '', + error: formatGitError(error), + errorObject: error, + }; + } +} + +function formatGitError(error) { + if (!error) { + return ''; + } + const stderr = + typeof error.stderr === 'string' + ? error.stderr + : error.stderr?.toString?.() || error.message || ''; + const firstLine = stderr + .split('\n') + .map(line => line.trim()) + .filter(Boolean)[0]; + if (firstLine) { + return firstLine; + } + if (error.code) { + return `git remote probe failed with ${error.code}`; + } + if (error.signal) { + return `git remote probe terminated by ${error.signal}`; + } + return String(error); +} + +function isRemoteProbeUnavailable(error, message) { + if (!error && !message) { + return false; + } + return ( + isNetworkFailure(message) || + error?.code === 'ETIMEDOUT' || + error?.signal === 'SIGTERM' || + error?.signal === 'SIGKILL' + ); +} + +function isNetworkFailure(message) { + return /failed to connect|couldn't connect|connection timed out|operation timed out|timed? out|timeout|etimedout|could not resolve|network is unreachable|connection reset|early eof|http\/2/i.test( + message || '' + ); } function readLocalCheckout(localPath) { @@ -69,10 +153,15 @@ function localHead(entry, remote) { const repoName = entry.repository.split('/')[1]; const candidates = [ { source: 'sibling', checkout: readLocalCheckout(join(codeRoot, repoName)) }, - { source: 'latest-worktree', checkout: readLocalCheckout(join(codeRoot, '.worktrees-lsp-latest', repoName)) }, + { + source: 'latest-worktree', + checkout: readLocalCheckout(join(codeRoot, '.worktrees-lsp-latest', repoName)), + }, { source: 'wiki-agent-worktree', - checkout: readLocalCheckout(join(codeRoot, '.worktrees-lsp-wiki-agent-cli-20260612', repoName)), + checkout: readLocalCheckout( + join(codeRoot, '.worktrees-lsp-wiki-agent-cli-20260612', repoName) + ), }, ]; @@ -108,7 +197,11 @@ function agentHelpProbe(entry, local) { const localPath = local.path; const env = { ...process.env }; const sourcePath = join(localPath, 'src'); - env.PYTHONPATH = [existsSync(sourcePath) ? sourcePath : localPath, localPath, process.env.PYTHONPATH] + env.PYTHONPATH = [ + existsSync(sourcePath) ? sourcePath : localPath, + localPath, + process.env.PYTHONPATH, + ] .filter(Boolean) .join(':'); @@ -146,19 +239,24 @@ function agentHelpProbe(entry, local) { } const rows = entries.map(entry => { - const remote = remoteHead(entry); + const remoteProbe = remoteHead(entry); + const remote = remoteProbe.head; const local = localHead(entry, remote); const metadata = readiness.get(entry.id); - const agentHelp = agentHelpProbe(entry, local); - const status = !local.exists - ? 'missing-local' - : local.head === remote - ? local.dirty - ? 'latest-with-local-changes' - : local.source === 'latest-worktree' - ? 'latest-via-worktree' - : 'latest' - : 'not-at-remote-head'; + const agentHelp = remoteProbe.error + ? { status: 'skipped', detail: 'remote-unavailable' } + : agentHelpProbe(entry, local); + const status = remoteProbe.error + ? 'remote-unavailable' + : !local.exists + ? 'missing-local' + : local.head === remote + ? local.dirty + ? 'latest-with-local-changes' + : local.source === 'latest-worktree' + ? 'latest-via-worktree' + : 'latest' + : 'not-at-remote-head'; return { ...entry, @@ -169,30 +267,47 @@ const rows = entries.map(entry => { dirty: local.dirty ? 'yes' : 'no', agentHelp: agentHelp.status, agentHelpDetail: agentHelp.detail, + remoteError: remoteProbe.error, status, }; }); -console.table(rows.map(row => ({ - id: row.id, - language: row.languageId, - branch: row.defaultBranch, - source: row.source, - local: row.local, - remote: row.remote, - dirty: row.dirty, - agentCli: row.agentCli, - agentHelp: row.agentHelp, - status: row.status, -}))); - -const drift = rows.filter(row => row.status === 'not-at-remote-head' || row.status === 'missing-local'); -const agentFailures = rows.filter(row => row.agentHelp === 'fail' || row.agentHelp === 'missing-build'); +console.table( + rows.map(row => ({ + id: row.id, + language: row.languageId, + branch: row.defaultBranch, + source: row.source, + local: row.local, + remote: row.remote, + dirty: row.dirty, + agentCli: row.agentCli, + agentHelp: row.agentHelp, + status: row.status, + })) +); + +const drift = rows.filter( + row => row.status === 'not-at-remote-head' || row.status === 'missing-local' +); +const remoteFailures = rows.filter(row => row.status === 'remote-unavailable'); +const agentFailures = rows.filter( + row => row.agentHelp === 'fail' || row.agentHelp === 'missing-build' +); if (drift.length > 0) { - console.log(`\n${drift.length} LSP checkout(s) are not at the configured remote default-branch HEAD.`); + console.log( + `\n${drift.length} LSP checkout(s) are not at the configured remote default-branch HEAD.` + ); console.log('Run again after updating sibling checkouts, or use this as release-note evidence.'); } +if (remoteFailures.length > 0) { + console.log(`\n${remoteFailures.length} LSP remote HEAD check(s) could not reach GitHub.`); + for (const failure of remoteFailures) { + console.log(`- ${failure.id}: ${failure.remoteError}`); + } +} + if (agentFailures.length > 0) { console.log(`\n${agentFailures.length} LSP agent CLI probe(s) failed.`); for (const failure of agentFailures) { @@ -200,6 +315,6 @@ if (agentFailures.length > 0) { } } -if (failOnDrift && (drift.length > 0 || agentFailures.length > 0)) { +if (failOnDrift && (drift.length > 0 || agentFailures.length > 0 || remoteFailures.length > 0)) { process.exitCode = 1; } diff --git a/scripts/generate-lsp-compatibility-doc.mjs b/scripts/generate-lsp-compatibility-doc.mjs index f373bda..e2a04c7 100644 --- a/scripts/generate-lsp-compatibility-doc.mjs +++ b/scripts/generate-lsp-compatibility-doc.mjs @@ -12,12 +12,21 @@ import { execFileSync } from 'child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import { dirname, join, relative, resolve } from 'path'; +import { basename, dirname, join, relative, resolve } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); -const codeRoot = resolve(repoRoot, '..'); +const codeRoot = resolveCodeRoot(repoRoot); + +function resolveCodeRoot(root) { + const parent = resolve(root, '..'); + if (basename(parent) !== '.worktrees') return parent; + const worktreeContainer = resolve(parent, '..'); + return existsSync(join(worktreeContainer, '.git')) + ? resolve(worktreeContainer, '..') + : worktreeContainer; +} const args = process.argv.slice(2); const checkMode = args.includes('--check'); diff --git a/scripts/lsp-release-matrix.json b/scripts/lsp-release-matrix.json new file mode 100644 index 0000000..b8aae99 --- /dev/null +++ b/scripts/lsp-release-matrix.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "openqc.lsp.release-matrix.v1", + "entries": { + "abacus-lsp": { "package": "abacus-lsp", "targetVersion": "0.1.1", "channel": "pypi" }, + "abinit-lsp": { "package": "abinit-lsp", "targetVersion": "0.1.1", "channel": "pypi" }, + "cif-lsp": { + "package": "newtontech.cif-lsp", + "targetVersion": "1.0.3", + "channel": "marketplace" + }, + "cp2k-lsp-enhanced": { + "package": "cp2k-lsp-enhanced", + "targetVersion": "0.1.1", + "channel": "pypi" + }, + "vasp-lsp": { "package": "vasp-lsp", "targetVersion": "0.4.5", "channel": "pypi" }, + "gaussian-lsp": { "package": "gaussian-lsp", "targetVersion": "0.2.12", "channel": "pypi" }, + "orca-lsp": { "package": "orca-lsp", "targetVersion": "0.5.6", "channel": "pypi" }, + "qe-lsp": { "package": "qe-lsp", "targetVersion": "0.1.1", "channel": "pypi" }, + "gamess-lsp": { "package": "gamess-lsp", "targetVersion": "0.1.1", "channel": "pypi" }, + "nwchem-lsp": { "package": "nwchem-lsp", "targetVersion": "0.5.0", "channel": "pypi" }, + "gpumd-lsp": { "package": "gpumd-lsp", "targetVersion": "0.1.1", "channel": "pypi" }, + "gromacs-lsp": { "package": "gromacs-lsp", "targetVersion": "0.0.4", "channel": "pypi" }, + "lammps-lsp": { + "package": "lammps-lsp", + "targetVersion": "0.1.0-pre.4", + "channel": "github-release" + }, + "mlip-lsp": { "package": "mlip-lsp", "targetVersion": "0.2.1", "channel": "pypi" }, + "pyatb-lsp": { "package": "pyatb-lsp", "targetVersion": "0.1.2", "channel": "pypi" }, + "pyscf-lsp": { "package": "pyscf-lsp", "targetVersion": "0.1.1", "channel": "pypi" }, + "dpgen-lsp": { "package": "dpgen-lsp", "targetVersion": "0.1.2", "channel": "pypi" } + } +} diff --git a/src/lsp/commandResolver.ts b/src/lsp/commandResolver.ts index 717fc10..cacac0f 100644 --- a/src/lsp/commandResolver.ts +++ b/src/lsp/commandResolver.ts @@ -112,19 +112,46 @@ export function resolveLspCommand( * - `openqc.lsp..command` * - `openqc.lsp..args` * - `openqc.lsp..env` + * + * Only user/workspace/language overrides are returned. Extension-contributed + * defaults from package.json are intentionally ignored so local sibling LSP + * checkouts can still be auto-detected. */ export function readCommandOverrides( config: vscode.WorkspaceConfiguration, languageId: string ): LspCommandOverrides { return { - path: config.get(`${languageId}.path`, undefined), - command: config.get(`${languageId}.command`, undefined), - args: config.get(`${languageId}.args`, undefined), - env: config.get | undefined>(`${languageId}.env`, undefined), + path: readExplicitConfigValue(config, `${languageId}.path`), + command: readExplicitConfigValue(config, `${languageId}.command`), + args: readExplicitConfigValue(config, `${languageId}.args`), + env: readExplicitConfigValue>(config, `${languageId}.env`), }; } +function readExplicitConfigValue( + config: vscode.WorkspaceConfiguration, + key: string +): T | undefined { + const inspection = typeof config.inspect === 'function' ? config.inspect(key) : undefined; + if (inspection) { + return firstDefined( + inspection.workspaceFolderLanguageValue, + inspection.workspaceLanguageValue, + inspection.globalLanguageValue, + inspection.workspaceFolderValue, + inspection.workspaceValue, + inspection.globalValue + ); + } + + return config.get(key, undefined); +} + +function firstDefined(...values: Array): T | undefined { + return values.find((value): value is T => value !== undefined); +} + // --------------------------------------------------------------------------- // Executable existence check (cross-platform) // --------------------------------------------------------------------------- diff --git a/src/lsp/dslAuthoringContext.ts b/src/lsp/dslAuthoringContext.ts index d4bf478..3cecb45 100644 --- a/src/lsp/dslAuthoringContext.ts +++ b/src/lsp/dslAuthoringContext.ts @@ -523,28 +523,24 @@ function probeSectionKeywordSchema( return { status: 'unavailable' }; } - // For the built-in fallback we look up the keyword at the given position - // from our static schema. Real LSP integration would send a - // textDocument/hover or custom request, but here we provide the best - // static match. We just return the first schema entry as a demonstration; - // a real implementation would inspect the document text at `position`. - // The test suite verifies that the status is correct and data shape is - // correct when the language has schema data. const entries = Object.values(schemaMap); if (entries.length === 0) { return { status: 'unavailable' }; } - // Return the first schema entry as representative context. - const first = entries[0]; + // The command API receives cursor coordinates but not document text, so this + // built-in fallback returns a stable nearby schema hint instead of pretending + // to resolve the exact token under the cursor. + const index = Math.abs(position.line + position.character) % entries.length; + const selected = entries[index]; return { status: 'available', data: { - name: first.name, - description: first.description, - allowedValues: first.allowedValues, - defaultValue: first.defaultValue, - type: first.type, + name: selected.name, + description: selected.description, + allowedValues: selected.allowedValues, + defaultValue: selected.defaultValue, + type: selected.type, }, }; } diff --git a/tests/unit/lsp/checkLspInstalledScript.test.ts b/tests/unit/lsp/checkLspInstalledScript.test.ts new file mode 100644 index 0000000..c861747 --- /dev/null +++ b/tests/unit/lsp/checkLspInstalledScript.test.ts @@ -0,0 +1,135 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'fs'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; +import { spawnSync } from 'child_process'; + +const scriptPath = resolve(__dirname, '../../../scripts/check-lsp-installed.mjs'); + +describe('check-lsp-installed script', () => { + let root: string; + let codeRoot: string; + let runtimeRoot: string; + let registryPath: string; + let matrixPath: string; + let fakeBin: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'openqc-installed-lsp-')); + codeRoot = join(root, 'code'); + runtimeRoot = join(codeRoot, '.lsp-latest'); + registryPath = join(root, 'registry.ts'); + matrixPath = join(root, 'matrix.json'); + fakeBin = join(root, 'bin'); + mkdirSync(fakeBin, { recursive: true }); + mkdirSync(join(runtimeRoot, '.venv', 'bin'), { recursive: true }); + + const registryEntries: string[] = []; + const readinessEntries: string[] = []; + const matrixEntries: Record = {}; + for (let index = 0; index < 17; index++) { + const id = `sample-${index}-lsp`; + const repoName = `sample-${index}`; + const executable = `sample-${index}-server`; + const agent = `sample-${index}-tool`; + registryEntries.push(`{ + id: '${id}', + repository: 'newtontech/${repoName}', + executable: '${executable}', + languageId: 'sample-${index}', + defaultBranch: 'main', + }`); + readinessEntries.push(`'${id}': diagnosticReadiness('${agent}', 'partial')`); + matrixEntries[id] = { package: id, targetVersion: '1.0.0', channel: 'pypi' }; + + const source = join(codeRoot, '.worktrees-lsp-latest', repoName); + const installed = join(runtimeRoot, repoName); + mkdirSync(source, { recursive: true }); + mkdirSync(installed, { recursive: true }); + writeFileSync(join(source, 'package.json'), JSON.stringify({ version: '1.0.0' })); + writeFileSync(join(installed, 'package.json'), JSON.stringify({ version: '1.0.0' })); + writeFileSync(join(runtimeRoot, '.venv', 'bin', executable), 'server'); + writeFileSync(join(runtimeRoot, '.venv', 'bin', agent), 'agent'); + } + writeFileSync( + registryPath, + `const entries = [${registryEntries.join(',')}];\nconst readiness = {${readinessEntries.join(',')}};\n` + ); + writeFileSync( + matrixPath, + JSON.stringify({ schemaVersion: 'openqc.lsp.release-matrix.v1', entries: matrixEntries }) + ); + + const fakeGit = join(fakeBin, 'git'); + writeFileSync( + fakeGit, + `#!/bin/sh +path="$2" +command="$3" +if [ "$command" = "rev-parse" ]; then + if [ -n "$OPENQC_TEST_DRIFT" ] && echo "$path" | grep -q "/.lsp-latest/sample-0$"; then + printf '%040d\\n' 2 + else + printf '%040d\\n' 1 + fi + exit 0 +fi +if [ "$command" = "tag" ]; then + echo "v1.0.0" + exit 0 +fi +exit 1 +` + ); + chmodSync(fakeGit, 0o755); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function run(extraEnv: NodeJS.ProcessEnv = {}, args = ['--json']) { + return spawnSync(process.execPath, [scriptPath, ...args], { + encoding: 'utf8', + env: { + ...process.env, + ...extraEnv, + PATH: `${fakeBin}:${process.env.PATH}`, + OPENQC_LSP_CODE_ROOT: codeRoot, + OPENQC_LSP_RUNTIME_ROOT: runtimeRoot, + OPENQC_LSP_REGISTRY_PATH: registryPath, + OPENQC_LSP_RELEASE_MATRIX_PATH: matrixPath, + }, + }); + } + + it('reports all five provenance fields for a matching installed fleet', () => { + const result = run(); + + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.schemaVersion).toBe('openqc.lsp.runtime-ledger.v1'); + expect(report.summary).toEqual({ total: 17, passing: 17, failing: 0 }); + expect(report.entries[0]).toEqual( + expect.objectContaining({ + sourceCommit: expect.stringMatching(/^0+1$/), + installedCommit: expect.stringMatching(/^0+1$/), + tagVersion: '1.0.0', + manifestVersion: '1.0.0', + installedVersion: '1.0.0', + registryVersion: '1.0.0', + runtimeParity: 'pass', + }) + ); + }); + + it('fails strict mode when the installed commit drifts from the source', () => { + const result = run({ OPENQC_TEST_DRIFT: '1' }, ['--json', '--strict']); + + expect(result.status).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.summary.failing).toBe(1); + expect(report.entries[0].runtimeParity).toBe('fail'); + expect(report.entries[0].problems).toContain('installed-commit-drift'); + }); +}); diff --git a/tests/unit/lsp/checkLspLatestScript.test.ts b/tests/unit/lsp/checkLspLatestScript.test.ts new file mode 100644 index 0000000..d7915ab --- /dev/null +++ b/tests/unit/lsp/checkLspLatestScript.test.ts @@ -0,0 +1,162 @@ +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const SCRIPT_PATH = path.join(REPO_ROOT, 'scripts', 'check-lsp-latest.mjs'); + +describe('check-lsp-latest script remote fallback', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openqc-lsp-latest-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('tries SSH remote HEAD lookup when HTTPS ls-remote times out', () => { + const head = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const harness = createHarness(tempDir, { + localHead: head, + httpsError: 'fatal: unable to access: Operation timed out', + sshHead: head, + }); + + const result = runLatestCheck(harness); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('fake-lsp'); + expect(result.stdout).toContain('latest'); + expect(result.stdout).not.toContain('remote-unavailable'); + expect(readGitCalls(harness)).toContain('ls-remote https://github.com/newtontech/fake-lsp.git'); + expect(readGitCalls(harness)).toContain('ls-remote git@github.com:newtontech/fake-lsp.git'); + }); + + it('does not try SSH when HTTPS succeeds but local checkout is stale', () => { + const localHead = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const remoteHead = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const harness = createHarness(tempDir, { localHead, httpsHead: remoteHead }); + + const result = runLatestCheck(harness); + + expect(result.status).toBe(1); + expect(result.stdout).toContain('fake-lsp'); + expect(result.stdout).toContain('not-at-remote-head'); + expect(result.stdout).not.toContain('remote-unavailable'); + expect(readGitCalls(harness)).toContain('ls-remote https://github.com/newtontech/fake-lsp.git'); + expect(readGitCalls(harness)).not.toContain('ls-remote git@github.com:newtontech/fake-lsp.git'); + }); + + it('does not try SSH for non-network HTTPS ls-remote errors', () => { + const harness = createHarness(tempDir, { + localHead: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + httpsError: 'fatal: repository not found', + }); + + const result = runLatestCheck(harness); + + expect(result.status).toBe(1); + expect(result.stdout).toContain('remote-unavailable'); + expect(result.stdout).toContain('fake-lsp'); + expect(readGitCalls(harness)).toContain('ls-remote https://github.com/newtontech/fake-lsp.git'); + expect(readGitCalls(harness)).not.toContain('ls-remote git@github.com:newtontech/fake-lsp.git'); + }); +}); + +interface HarnessOptions { + localHead: string; + httpsHead?: string; + httpsError?: string; + sshHead?: string; +} + +interface Harness { + registryPath: string; + codeRoot: string; + binDir: string; + gitLog: string; +} + +function createHarness(tempDir: string, options: HarnessOptions): Harness { + const registryPath = path.join(tempDir, 'registry.ts'); + const codeRoot = path.join(tempDir, 'code'); + const repoRoot = path.join(codeRoot, 'fake-lsp'); + const binDir = path.join(tempDir, 'bin'); + const gitLog = path.join(tempDir, 'git.log'); + + fs.mkdirSync(path.join(repoRoot, '.git'), { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + registryPath, + [ + 'export const LSP_SERVERS = [{', + " id: 'fake-lsp',", + " repository: 'newtontech/fake-lsp',", + " languageId: 'fake',", + " defaultBranch: 'main',", + '}];', + ].join('\n') + ); + writeFakeGit(binDir, gitLog, options); + + return { registryPath, codeRoot, binDir, gitLog }; +} + +function runLatestCheck(harness: Harness): ReturnType { + return spawnSync(process.execPath, [SCRIPT_PATH, '--fail-on-drift'], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${harness.binDir}${path.delimiter}${process.env.PATH ?? ''}`, + OPENQC_LSP_REGISTRY_PATH: harness.registryPath, + OPENQC_LSP_CODE_ROOT: harness.codeRoot, + }, + }); +} + +function readGitCalls(harness: Harness): string { + return fs.readFileSync(harness.gitLog, 'utf8'); +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function writeFakeGit(binDir: string, logPath: string, options: HarnessOptions): void { + const fakeGit = path.join(binDir, 'git'); + const httpsBranch = options.httpsHead + ? ` https://*) printf '${options.httpsHead}\\trefs/heads/main\\n'; exit 0 ;;` + : ` https://*) echo ${shellSingleQuote(options.httpsError ?? 'fatal: unable to access')} >&2; exit 128 ;;`; + const sshBranch = options.sshHead + ? ` git@github.com:*) printf '${options.sshHead}\\trefs/heads/main\\n'; exit 0 ;;` + : ' git@github.com:*) echo "unexpected SSH fallback" >&2; exit 2 ;;'; + + fs.writeFileSync( + fakeGit, + [ + '#!/bin/sh', + `printf '%s\\n' "$*" >> "${logPath}"`, + 'if [ "$1" = "ls-remote" ]; then', + ' case "$2" in', + httpsBranch, + sshBranch, + ' esac', + 'fi', + 'if [ "$1" = "-C" ]; then', + ' if [ "$3" = "rev-parse" ]; then', + ` echo "${options.localHead}"; exit 0`, + ' fi', + ' if [ "$3" = "status" ]; then', + ' exit 0', + ' fi', + 'fi', + 'echo "unexpected git invocation: $*" >&2', + 'exit 2', + ].join('\n'), + { mode: 0o755 } + ); +}