ccppccpp home
Browse the documentation

Plugins · CPO

Language servers

Rules backed by real language servers: type errors a write introduces, what a call resolves to, deprecations.

CPO never installs a language server. It runs those of the official Claude Code LSP plugins from the PATH, and tells the user how to install a missing one. ccpp cpo lsp status shows which are installed.

lsp-diagnostics

.claude/policies/no-new-type-errors.policy.md
---
description: No new type errors
on: [file.write]
when:
  builtin: lsp-diagnostics
  with: { severity: error }                               # onlyNew: true by default
  where: "!facts.introduced.exists(d, d.code == 6133)"    # ignore "declared but never read"
---
This edit introduces {{ facts.count }} error(s) in {{ file.relative }}:
{{ facts.errors }}

On file.write, the server sees the file at its current text, then at the text about to be written (in memory: nothing touches the disk). The policy holds on the diagnostics the write introduces. A line shift keeps an error; a second identical error in the same function is new.

Across files, at the end of a turn

With since, the check covers every file changed since a snapshot, plus up to importers (10) files that reference their top-level symbols. A renamed export that breaks its importers is caught, which a per-write check cannot see.

snapshot-turn.policy.md
on: [turn.prompt]
when: { cel: "git.repo" }
then: [{ snapshot: { as: state.turn.start } }]
no-type-regressions.policy.md
---
description: Leave no type errors behind
on: [turn.end]
when: { builtin: lsp-diagnostics, with: { since: state.turn.start } }
---
You left {{ facts.count }} type error(s): {{ facts.errors }}

lsp-query

A tree-sitter query selects nodes of the content being written, then the server answers ask at each selected node (at most limit, 10):

askAnswer
definition{ path, relative, line, column }
typethe hover's signature and its valueType
referencesa count
deprecateda deprecated tag or @deprecated in the hover
on: [file.write]
paths: ["src/domain/**"]
when:
  builtin: lsp-query
  with:
    query: '(call_expression function: [(identifier) @match (member_expression property: (_) @match)])'
    language: [typescript, tsx]
    ask: [definition, deprecated]
    onlyNew: true
  where: "facts.introduced.exists(f, f.deprecated == true || (f.definition != null && glob(f.definition.relative, 'src/infra/**')))"

Unknown values are null: guard them in where.

Never blocking on the server

A policy step has 5 seconds, and an error or timeout blocks. So an lsp-* check gets a 4-second budget (CPO_LSP_BUDGET_MS) and turns everything it cannot judge into a skip: a missing server, one still loading the project, a late answer. Both builtins cost 60, so they run after cheaper policies; in an all, put them last.

Effects

on: [session.start]
then: [lsp-warm]        # starts the servers of the project's languages

lsp-warm never blocks. lsp-release, on session.end, tells the daemon a session ended.

The daemon

One daemon per project keeps the servers alive between hook calls, shared by parallel sessions. It starts with the first lsp-* policy, stops with its last session or after 30 idle minutes (CPO_LSP_IDLE_MS).

Command or variable
ccpp cpo lsp statusInstalled servers, how to install the others, the daemon's state.
ccpp cpo lsp stopStops the project's daemon.
CPO_LSP_COMMAND_<ID>A JSON array replacing a server's command line.

With TypeScript 7 or later in the project's node_modules, CPO runs its tsc --lsp --stdio and needs nothing else installed.

Edit this page on GitHub