@pugi/cli 0.1.0-beta.23 → 0.1.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/core/auto-update/channels.js +122 -0
  2. package/dist/core/auto-update/checker.js +241 -0
  3. package/dist/core/auto-update/state.js +235 -0
  4. package/dist/core/engine/compaction-hook.js +154 -0
  5. package/dist/core/engine/native-pugi.js +67 -3
  6. package/dist/core/engine/tool-bridge.js +123 -3
  7. package/dist/core/hooks/events.js +44 -0
  8. package/dist/core/hooks/index.js +15 -0
  9. package/dist/core/hooks/registry.js +213 -0
  10. package/dist/core/hooks/runner.js +236 -0
  11. package/dist/core/init/scaffold.js +195 -0
  12. package/dist/core/lsp/cache.js +105 -0
  13. package/dist/core/lsp/language-detect.js +66 -0
  14. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  15. package/dist/core/repl/codebase-survey.js +308 -0
  16. package/dist/core/repl/init-interview.js +457 -0
  17. package/dist/core/repl/onboarding-state.js +297 -0
  18. package/dist/core/repl/session.js +84 -0
  19. package/dist/core/repl/slash-commands.js +25 -0
  20. package/dist/core/repo-map/build.js +125 -0
  21. package/dist/core/repo-map/cache.js +185 -0
  22. package/dist/core/repo-map/extractor.js +254 -0
  23. package/dist/core/repo-map/formatter.js +145 -0
  24. package/dist/core/repo-map/scanner.js +211 -0
  25. package/dist/core/session.js +44 -0
  26. package/dist/core/settings.js +9 -0
  27. package/dist/runtime/cli.js +170 -0
  28. package/dist/runtime/commands/hooks.js +184 -0
  29. package/dist/runtime/commands/lsp.js +25 -23
  30. package/dist/runtime/commands/repo-map.js +95 -0
  31. package/dist/runtime/commands/update.js +289 -0
  32. package/dist/runtime/version.js +1 -1
  33. package/dist/tui/repl-splash-mascot.js +19 -7
  34. package/package.json +3 -3
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Pugi hooks MVP — runner (Leak L12, first pass).
3
+ *
4
+ * Spawns the shell command declared in `hooks-mvp.json`, applies the
5
+ * timeout watchdog, captures stdout / stderr, and surfaces a
6
+ * structured result. Two events are wired in the MVP (SessionStart,
7
+ * PreToolUse); the runner itself is event-agnostic so the fast-follow
8
+ * PR can attach the remaining 6 events without changing this file.
9
+ *
10
+ * Safety properties:
11
+ * - 30 s default timeout (per task spec); SIGTERM then SIGKILL with
12
+ * a 2 s grace window.
13
+ * - 1 MiB output cap per stream — a misbehaving hook (`yes`) cannot
14
+ * OOM the parent CLI by buffering unbounded data.
15
+ * - Spawn failures are caught + logged; the session never crashes
16
+ * because of a missing binary or a syntax error in the command.
17
+ * - Hook errors are atomic-appended to `<workspaceRoot>/.pugi/logs/
18
+ * hooks.log`. Multiple sessions can write concurrently without
19
+ * interleaving because `appendFileSync` opens with O_APPEND.
20
+ *
21
+ * Brand voice: ASCII only.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
25
+ import { resolve } from 'node:path';
26
+ import { DEFAULT_HOOK_TIMEOUT_MS, isToolEvent, } from './registry.js';
27
+ const HOOK_STREAM_CAP_BYTES = 1024 * 1024;
28
+ const SIGKILL_GRACE_MS = 2_000;
29
+ /**
30
+ * Fire every matching hook for `event` sequentially. Sequential (not
31
+ * parallel) is the intentional default — operators frequently chain
32
+ * `git add` -> `eslint --fix` style hooks that would race otherwise.
33
+ * Returns a `HookFireOutcome` with the per-invocation results.
34
+ */
35
+ export async function fireHooks(opts) {
36
+ const { config, event, payload, toolName, workspaceRoot, env } = opts;
37
+ const matching = config.listMatching(event, toolName);
38
+ if (matching.length === 0) {
39
+ return { event, results: [], anyBlocked: false };
40
+ }
41
+ const logger = workspaceRoot ? new HookLogger(workspaceRoot) : undefined;
42
+ const results = [];
43
+ let anyBlocked = false;
44
+ for (const entry of matching) {
45
+ const timeoutMs = entry.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
46
+ const result = await executeOne(entry.command, payload, timeoutMs, env);
47
+ // Blocking semantics only honored for PreToolUse in the MVP.
48
+ // Other events can declare `blocking: true` but the runner just
49
+ // logs that intent — it does NOT short-circuit. The fast-follow
50
+ // PR threads PostToolUse + UserPromptSubmit blocking through.
51
+ const blockable = entry.blocking === true && event === 'PreToolUse';
52
+ const blocked = blockable && !result.ok;
53
+ if (blocked) {
54
+ anyBlocked = true;
55
+ result.blocked = true;
56
+ result.blockSentinel = `HOOK_BLOCKED: ${truncate(entry.command, 80)} exited ${result.exitCode}`;
57
+ }
58
+ if (logger && !result.ok) {
59
+ logger.recordFailure(event, entry.command, result);
60
+ }
61
+ results.push(result);
62
+ }
63
+ return { event, results, anyBlocked };
64
+ }
65
+ async function executeOne(command, payload, timeoutMs, env) {
66
+ const startedAt = Date.now();
67
+ return new Promise((resolvePromise) => {
68
+ const payloadJson = JSON.stringify(payload);
69
+ const childEnv = {
70
+ ...(env ?? process.env),
71
+ PUGI_HOOK_PAYLOAD: payloadJson,
72
+ PUGI_HOOK_EVENT: payload.event,
73
+ PUGI_HOOK_SESSION_ID: payload.sessionId,
74
+ };
75
+ const child = spawn('/bin/sh', ['-c', command], {
76
+ env: childEnv,
77
+ stdio: ['pipe', 'pipe', 'pipe'],
78
+ });
79
+ const state = {
80
+ stdout: '',
81
+ stderr: '',
82
+ killedForTimeout: false,
83
+ killedForStreamCap: false,
84
+ };
85
+ const escalateKill = () => {
86
+ if (state.sigKillTimer)
87
+ return;
88
+ state.sigKillTimer = setTimeout(() => {
89
+ if (!child.killed)
90
+ child.kill('SIGKILL');
91
+ }, SIGKILL_GRACE_MS);
92
+ if (state.sigKillTimer.unref)
93
+ state.sigKillTimer.unref();
94
+ };
95
+ const enforceStreamCap = () => {
96
+ if (state.killedForStreamCap)
97
+ return;
98
+ if (state.stdout.length + state.stderr.length <= HOOK_STREAM_CAP_BYTES)
99
+ return;
100
+ state.killedForStreamCap = true;
101
+ child.kill('SIGTERM');
102
+ escalateKill();
103
+ };
104
+ child.stdout?.on('data', (chunk) => {
105
+ if (state.killedForStreamCap)
106
+ return;
107
+ state.stdout += chunk.toString('utf8');
108
+ enforceStreamCap();
109
+ });
110
+ child.stderr?.on('data', (chunk) => {
111
+ if (state.killedForStreamCap)
112
+ return;
113
+ state.stderr += chunk.toString('utf8');
114
+ enforceStreamCap();
115
+ });
116
+ // Best-effort stdin payload — hook scripts that want to read it can
117
+ // (e.g. `jq .`); scripts that ignore stdin will EPIPE on our write
118
+ // which we swallow because the env var carries the same data.
119
+ if (child.stdin) {
120
+ child.stdin.on('error', () => {
121
+ // EPIPE is benign — see above.
122
+ });
123
+ child.stdin.end(payloadJson);
124
+ }
125
+ const timer = setTimeout(() => {
126
+ state.killedForTimeout = true;
127
+ child.kill('SIGTERM');
128
+ escalateKill();
129
+ }, timeoutMs);
130
+ if (timer.unref)
131
+ timer.unref();
132
+ child.on('error', (error) => {
133
+ clearTimeout(timer);
134
+ if (state.sigKillTimer)
135
+ clearTimeout(state.sigKillTimer);
136
+ resolvePromise({
137
+ command: truncate(command, 200),
138
+ exitCode: -1,
139
+ stdoutBytes: state.stdout.length,
140
+ stderrBytes: state.stderr.length,
141
+ elapsedMs: Date.now() - startedAt,
142
+ ok: false,
143
+ blocked: false,
144
+ timedOut: false,
145
+ // No blockSentinel here — spawn errors are not the same as
146
+ // blocking-failure semantics. The caller logs them generically.
147
+ });
148
+ });
149
+ child.on('close', (code, signal) => {
150
+ clearTimeout(timer);
151
+ if (state.sigKillTimer)
152
+ clearTimeout(state.sigKillTimer);
153
+ let exitCode;
154
+ if (code !== null) {
155
+ exitCode = code;
156
+ }
157
+ else if (signal === 'SIGTERM') {
158
+ exitCode = -15;
159
+ }
160
+ else if (signal === 'SIGKILL') {
161
+ exitCode = -9;
162
+ }
163
+ else {
164
+ exitCode = -1;
165
+ }
166
+ const ok = exitCode === 0 &&
167
+ !state.killedForTimeout &&
168
+ !state.killedForStreamCap;
169
+ resolvePromise({
170
+ command: truncate(command, 200),
171
+ exitCode,
172
+ stdoutBytes: state.stdout.length,
173
+ stderrBytes: state.stderr.length,
174
+ elapsedMs: Date.now() - startedAt,
175
+ ok,
176
+ blocked: false,
177
+ timedOut: state.killedForTimeout,
178
+ });
179
+ });
180
+ });
181
+ }
182
+ /**
183
+ * Append-only failure log at `<workspaceRoot>/.pugi/logs/hooks.log`.
184
+ * Each line is a JSON record so log scrapers can `jq` over it.
185
+ */
186
+ class HookLogger {
187
+ path;
188
+ prepared = false;
189
+ constructor(workspaceRoot) {
190
+ this.path = resolve(workspaceRoot, '.pugi', 'logs', 'hooks.log');
191
+ }
192
+ recordFailure(event, command, result) {
193
+ this.prepareDir();
194
+ const line = JSON.stringify({
195
+ ts: new Date().toISOString(),
196
+ event,
197
+ command: truncate(command, 200),
198
+ exitCode: result.exitCode,
199
+ timedOut: result.timedOut,
200
+ elapsedMs: result.elapsedMs,
201
+ stdoutBytes: result.stdoutBytes,
202
+ stderrBytes: result.stderrBytes,
203
+ toolEvent: isToolEvent(event),
204
+ });
205
+ try {
206
+ appendFileSync(this.path, `${line}\n`, 'utf8');
207
+ }
208
+ catch {
209
+ // Logging is best-effort — the session must not crash when the
210
+ // disk is full or the directory is read-only. The runner has
211
+ // already returned the result; dropping the log line is the
212
+ // safe fallback.
213
+ }
214
+ }
215
+ prepareDir() {
216
+ if (this.prepared)
217
+ return;
218
+ const dir = resolve(this.path, '..');
219
+ if (!existsSync(dir)) {
220
+ try {
221
+ mkdirSync(dir, { recursive: true });
222
+ }
223
+ catch {
224
+ // ignored — appendFileSync will surface a fresh error on the
225
+ // write path, which we also swallow.
226
+ }
227
+ }
228
+ this.prepared = true;
229
+ }
230
+ }
231
+ function truncate(value, max) {
232
+ if (value.length <= max)
233
+ return value;
234
+ return `${value.slice(0, max - 3)}...`;
235
+ }
236
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Workspace scaffold — extracted from `pugi init` so the bare REPL boot
3
+ * can call it automatically when the operator launches `pugi` in a
4
+ * fresh directory (CEO directive 2026-05-26).
5
+ *
6
+ * Before this module, `pugi init` was the only path that materialised
7
+ * `.pugi/` + the canonical config files. Launching the REPL in an empty
8
+ * directory printed `workspace: (not bound - run /init OR cd into
9
+ * project)` and instructed the operator to Ctrl+C, run `pugi init`,
10
+ * relaunch. That round trip is hostile on a first-touch install — CEO
11
+ * escalated "auto = решение" on 2026-05-26.
12
+ *
13
+ * The module is intentionally side-effect free at import time: the
14
+ * scaffold runs only when `ensureWorkspaceInitialized` is called. The
15
+ * scaffold is also idempotent — every file write is gated by an
16
+ * `existsSync` check, so re-running against a workspace that already has
17
+ * `.pugi/settings.json` (e.g. a manual `pugi init` followed by auto-init
18
+ * on next REPL launch) is a no-op. The function is safe to call before
19
+ * any other init logic.
20
+ *
21
+ * Two CRITICAL invariants:
22
+ *
23
+ * 1. **Atomic per-file.** Every write uses `existsSync` + `writeFileSync`
24
+ * against the final path. There is no read-modify-write pattern that
25
+ * could lose data on a concurrent `pugi init` race. The one path
26
+ * that DOES mutate an existing file — `.gitignore` (append `.pugi/`
27
+ * marker) — also gates on the marker being absent before appending,
28
+ * so the worst-case race is a duplicate marker line that the next
29
+ * run skips.
30
+ *
31
+ * 2. **Silent by default.** When `opts.silent` is true (the REPL
32
+ * auto-init path) the scaffold writes NOTHING to stderr/stdout.
33
+ * The REPL bootstrap runs before Ink mounts, and a stray
34
+ * stdout/stderr write at that point would land on the operator's
35
+ * shell ABOVE the alt-screen entry — visible until they scroll up,
36
+ * and noisy in a CI tail. The explicit `pugi init` path stays
37
+ * verbose via the standalone command in `runtime/cli.ts`.
38
+ */
39
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
40
+ import { resolve } from 'node:path';
41
+ import { emptyIndex } from '../index-store.js';
42
+ /**
43
+ * Materialise the canonical `.pugi/` workspace scaffold under `cwd`.
44
+ * Returns a `{created, dir, createdPaths, skippedPaths}` summary so the
45
+ * caller can log a one-shot "initialized" line on the first call without
46
+ * re-checking the filesystem.
47
+ *
48
+ * The scaffold mirrors `pugi init` minus the bundled default-skills
49
+ * install (that is a heavier operation gated on the `--no-defaults`
50
+ * flag, and the standalone `pugi init` command keeps owning it).
51
+ *
52
+ * Idempotent: every file write gates on `existsSync`, so re-running
53
+ * against an existing workspace is a no-op and returns
54
+ * `{created: false}` with every path in `skippedPaths`.
55
+ */
56
+ export function ensureWorkspaceInitialized(cwd, opts = {}) {
57
+ const silent = opts.silent !== false;
58
+ const pugiDir = resolve(cwd, '.pugi');
59
+ // Local trackers so the existing helpers (mkdirIfMissing /
60
+ // writeJsonIfMissing / writeTextIfMissing) keep their (created, skipped)
61
+ // signature. The explicit `pugi init` command forwards these straight
62
+ // into its JSON payload.
63
+ const created = [];
64
+ const skipped = [];
65
+ mkdirIfMissing(pugiDir, created, skipped);
66
+ mkdirIfMissing(resolve(pugiDir, 'artifacts'), created, skipped);
67
+ mkdirIfMissing(resolve(pugiDir, 'sessions'), created, skipped);
68
+ mkdirIfMissing(resolve(pugiDir, 'skills'), created, skipped);
69
+ writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
70
+ schema: 1,
71
+ workflow: {
72
+ brand: 'pugi',
73
+ legacyName: 'codeforge',
74
+ approvals: 'auto',
75
+ notAutomatic: [],
76
+ defaultBaseBranch: 'dev',
77
+ branchPrefixes: ['feature', 'fix', 'refactor', 'chore'],
78
+ aiCoAuthorTrailers: false,
79
+ },
80
+ permissions: {
81
+ mode: 'auto',
82
+ allow: [],
83
+ deny: [],
84
+ notAutomatic: [],
85
+ },
86
+ privacy: {
87
+ mode: 'balanced',
88
+ telemetry: 'off',
89
+ },
90
+ artifacts: {
91
+ defaultPath: '.pugi/artifacts',
92
+ promoteExplicitly: true,
93
+ },
94
+ }, created, skipped);
95
+ writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), { schema: 1, servers: [] }, created, skipped);
96
+ writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
97
+ writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
98
+ '# Pugi Project Context',
99
+ '',
100
+ '## Product Workflow',
101
+ '',
102
+ '- Public product name: Pugi',
103
+ '- Default flow: idea -> build -> review',
104
+ '- Approvals are automatic by default until a repo, environment, workflow, or action is marked notAutomatic.',
105
+ '- Do not add AI Co-Authored-By trailers.',
106
+ '- Generated code, comments, commits, PR text, and technical docs default to English.',
107
+ '',
108
+ '## Project Notes',
109
+ '',
110
+ '- Add repo-specific architecture, commands, and business rules here.',
111
+ '- Do not store secrets, real IPs, private key paths, tokens, or credentials here.',
112
+ '',
113
+ ].join('\n'), created, skipped);
114
+ writeTextIfMissing(resolve(cwd, '.pugiignore'), [
115
+ '# Pugi ignore rules',
116
+ '.env',
117
+ '.env.*',
118
+ '!.env.example',
119
+ 'node_modules/',
120
+ 'dist/',
121
+ '.next/',
122
+ 'coverage/',
123
+ '*.log',
124
+ '*.pem',
125
+ '*.key',
126
+ '*.crt',
127
+ '*.p12',
128
+ '*.sql',
129
+ '*.dump',
130
+ '',
131
+ ].join('\n'), created, skipped);
132
+ ensurePugiGitIgnore(cwd, created, skipped);
133
+ // `silent` is honoured implicitly — this module never writes to
134
+ // stdout/stderr. The flag exists so the standalone `pugi init` command
135
+ // can layer its own logger on top (it does, in runtime/cli.ts), while
136
+ // the auto-init REPL path leaves the boot stream untouched. We
137
+ // reference the flag here to defeat the lint "unused" warning and to
138
+ // document the contract in the source.
139
+ void silent;
140
+ return {
141
+ created: created.length > 0,
142
+ dir: pugiDir,
143
+ createdPaths: created,
144
+ skippedPaths: skipped,
145
+ };
146
+ }
147
+ /* ------------------------------------------------------------------ */
148
+ /* Helpers (mirror the previous in-file implementations in cli.ts) */
149
+ /* ------------------------------------------------------------------ */
150
+ function mkdirIfMissing(path, created, skipped) {
151
+ if (existsSync(path)) {
152
+ skipped.push(path);
153
+ return;
154
+ }
155
+ mkdirSync(path, { recursive: true });
156
+ created.push(path);
157
+ }
158
+ function writeJsonIfMissing(path, value, created, skipped) {
159
+ writeTextIfMissing(path, `${JSON.stringify(value, null, 2)}\n`, created, skipped);
160
+ }
161
+ function writeTextIfMissing(path, value, created, skipped) {
162
+ if (existsSync(path)) {
163
+ skipped.push(path);
164
+ return;
165
+ }
166
+ writeFileSync(path, value, { encoding: 'utf8', mode: 0o600 });
167
+ created.push(path);
168
+ }
169
+ /**
170
+ * Ensure the workspace `.gitignore` ignores `.pugi/`. The function is
171
+ * additive: it leaves an existing `.gitignore` body intact and appends
172
+ * the marker only when none of `.pugi/`, `/.pugi/`, or `.pugi` is
173
+ * already present. On a fresh repo with no `.gitignore` it creates the
174
+ * file with the single marker line. Mode 0o600 matches the rest of the
175
+ * scaffold so a paranoid CI does not surface "world-readable" warnings.
176
+ */
177
+ function ensurePugiGitIgnore(cwd, created, skipped) {
178
+ const gitignorePath = resolve(cwd, '.gitignore');
179
+ const marker = '.pugi/';
180
+ if (!existsSync(gitignorePath)) {
181
+ writeFileSync(gitignorePath, `${marker}\n`, { encoding: 'utf8', mode: 0o600 });
182
+ created.push(gitignorePath);
183
+ return;
184
+ }
185
+ const current = readFileSync(gitignorePath, 'utf8');
186
+ const lines = current.split('\n').map((line) => line.trim());
187
+ if (lines.includes(marker) || lines.includes('/.pugi/') || lines.includes('.pugi')) {
188
+ skipped.push(gitignorePath);
189
+ return;
190
+ }
191
+ const next = current.endsWith('\n') ? `${current}${marker}\n` : `${current}\n${marker}\n`;
192
+ writeFileSync(gitignorePath, next, { encoding: 'utf8' });
193
+ created.push(`${gitignorePath} (+${marker})`);
194
+ }
195
+ //# sourceMappingURL=scaffold.js.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Per-process LSP client cache — Leak L15.
3
+ *
4
+ * The α7.7 `runtime/commands/lsp.ts` CLI surface spawns one LSP server
5
+ * per invocation and stops it at the end. That is correct for the
6
+ * one-shot `pugi lsp hover ...` shape but wrong for L15's
7
+ * post-edit auto-diagnostics: every successful `edit`/`write` would
8
+ * otherwise pay the ~2-3s cold-start of `typescript-language-server`,
9
+ * which is unusable inside an agent loop.
10
+ *
11
+ * This module owns a singleton map keyed by `LspLanguage` with lazy
12
+ * initialization (`getOrStart`). The first edit of a TS file in a
13
+ * session pays cold-start; every subsequent edit of any TS/TSX file
14
+ * in the same workspace reuses the warm client.
15
+ *
16
+ * Lifecycle:
17
+ * - `getOrStart(lang, cwd)` — spawn if missing, return cached otherwise.
18
+ * - `stopAll()` — graceful shutdown of every cached client. Called from
19
+ * `runCli` exit so a Ctrl-C never leaves zombie LSP processes behind.
20
+ * - `reset()` — test-only escape hatch, drops the cache without
21
+ * touching child processes (specs inject stubs that own their own
22
+ * lifecycle).
23
+ *
24
+ * Failure handling: a startup failure is NOT cached. The next call
25
+ * tries again. This keeps the cache from poisoning a session when the
26
+ * operator installs the missing LSP binary mid-session and re-edits.
27
+ *
28
+ * Brand voice: ASCII only, no emoji, no banned words.
29
+ */
30
+ import { isLspLanguageDisabled, startLspClient, } from './client.js';
31
+ const cache = new Map();
32
+ /**
33
+ * Return a warm client for `lang`, starting one if needed. The
34
+ * workspace `cwd` is captured at cache-insert time; if a subsequent
35
+ * call asks for the same language with a different `cwd` we tear
36
+ * down the old client and start a fresh one. This handles the
37
+ * agent-worktree case where the same process hops between workspace
38
+ * roots inside one Node lifetime.
39
+ */
40
+ export async function getOrStartLspClient(lang, opts) {
41
+ // β7 L9: respect the per-language disable toggle BEFORE we attempt to
42
+ // spawn. The check is cheap and keeps the disabled path from paying
43
+ // the `npx --yes` warmup cost on first use.
44
+ if (isLspLanguageDisabled(lang, opts.lspSettings)) {
45
+ return {
46
+ ok: false,
47
+ reason: 'lsp_disabled',
48
+ detail: `${lang} is disabled via .pugi/settings.json::lsp`,
49
+ };
50
+ }
51
+ const existing = cache.get(lang);
52
+ if (existing && existing.cwd === opts.cwd) {
53
+ return { ok: true, client: existing.client };
54
+ }
55
+ if (existing && existing.cwd !== opts.cwd) {
56
+ // Workspace switched — stop the old client and fall through to spawn.
57
+ try {
58
+ await existing.client.stop();
59
+ }
60
+ catch {
61
+ // best effort; stop() is idempotent + swallow-safe
62
+ }
63
+ cache.delete(lang);
64
+ }
65
+ const result = await startLspClient(lang, opts);
66
+ if (!result.ok) {
67
+ return { ok: false, reason: result.reason, detail: result.detail };
68
+ }
69
+ cache.set(lang, { client: result.value, cwd: opts.cwd });
70
+ return { ok: true, client: result.value };
71
+ }
72
+ /** Look up the cached client without starting one. Returns undefined when missing. */
73
+ export function peekLspClient(lang) {
74
+ return cache.get(lang)?.client;
75
+ }
76
+ /** Snapshot of currently-cached languages — used by `pugi lsp status` debug output. */
77
+ export function listCachedLanguages() {
78
+ return Array.from(cache.keys());
79
+ }
80
+ /**
81
+ * Stop every cached client and clear the cache. Called from `runCli`
82
+ * exit and from specs that own the lifecycle of their stub servers.
83
+ */
84
+ export async function stopAllLspClients() {
85
+ const snapshot = Array.from(cache.values());
86
+ cache.clear();
87
+ await Promise.all(snapshot.map(async (entry) => {
88
+ try {
89
+ await entry.client.stop();
90
+ }
91
+ catch {
92
+ // best effort — shutting down anyway
93
+ }
94
+ }));
95
+ }
96
+ /**
97
+ * Test-only: drop the cache map WITHOUT calling stop on the children.
98
+ * Specs that inject stub servers manage the stub lifecycle themselves;
99
+ * this lets a spec swap a stub mid-test without the cache holding a
100
+ * stale reference to a torn-down process.
101
+ */
102
+ export function __resetLspCacheForTests() {
103
+ cache.clear();
104
+ }
105
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Language-from-extension detection — Leak L15.
3
+ *
4
+ * Single source of truth for "given a file path, which `LspLanguage`
5
+ * slug do we route to". The α7.7 `runtime/commands/lsp.ts` shipped its
6
+ * own inline `inferLanguage` switch; L15 (post-edit auto-diagnostics)
7
+ * needs the same lookup from `core/engine/tool-bridge.ts`, so we lift
8
+ * the table into a dedicated module to avoid a second copy drifting
9
+ * out of sync.
10
+ *
11
+ * Returning `undefined` is the calling code's signal to silently skip
12
+ * LSP — an unsupported extension is NOT an error, it just means "no
13
+ * diagnostics for this file". The tool-bridge hook treats this as a
14
+ * no-op envelope tail.
15
+ *
16
+ * Adding a new language requires THREE coordinated changes:
17
+ * 1. Add the `LspLanguage` slug + server descriptor in `client.ts`.
18
+ * 2. Map its extensions here.
19
+ * 3. Add a `lsp-language-matrix` spec row exercising the new ext.
20
+ *
21
+ * Brand voice: ASCII only, no emoji, no banned words.
22
+ */
23
+ import { extname } from 'node:path';
24
+ /**
25
+ * Lower-case extension (including the dot) → LSP language slug.
26
+ * Mirror of the switch in `runtime/commands/lsp.ts::inferLanguage`.
27
+ * The table form lets tests assert coverage and lets new languages
28
+ * land with one edit instead of two.
29
+ */
30
+ export const EXTENSION_TO_LANGUAGE = {
31
+ '.ts': 'ts',
32
+ '.tsx': 'ts',
33
+ '.mts': 'ts',
34
+ '.cts': 'ts',
35
+ '.js': 'js',
36
+ '.jsx': 'js',
37
+ '.mjs': 'js',
38
+ '.cjs': 'js',
39
+ '.py': 'py',
40
+ '.pyi': 'py',
41
+ '.go': 'go',
42
+ '.rs': 'rust',
43
+ };
44
+ /**
45
+ * Infer the `LspLanguage` for a workspace-relative or absolute path.
46
+ * Returns `undefined` for unmapped extensions — the caller decides
47
+ * whether that is silently skipped (post-edit hook) or surfaced as
48
+ * `language_unsupported` (`pugi lsp` CLI).
49
+ */
50
+ export function languageForFile(file) {
51
+ const ext = extname(file).toLowerCase();
52
+ if (!ext)
53
+ return undefined;
54
+ return EXTENSION_TO_LANGUAGE[ext];
55
+ }
56
+ /**
57
+ * Return every extension currently mapped to the given language.
58
+ * Used by the matrix spec to assert coverage without re-typing the
59
+ * extension list.
60
+ */
61
+ export function extensionsForLanguage(lang) {
62
+ return Object.entries(EXTENSION_TO_LANGUAGE)
63
+ .filter(([, value]) => value === lang)
64
+ .map(([ext]) => ext);
65
+ }
66
+ //# sourceMappingURL=language-detect.js.map