@pugi/cli 0.1.0-beta.100 → 0.1.0-beta.101
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.
- package/README.md +2 -0
- package/dist/core/codegraph/parser.js +574 -47
- package/dist/core/codegraph/queries/go.scm +57 -0
- package/dist/core/codegraph/queries/javascript.scm +56 -0
- package/dist/core/codegraph/queries/python.scm +55 -0
- package/dist/core/codegraph/queries/rust.scm +63 -0
- package/dist/core/codegraph/queries/typescript.scm +91 -0
- package/dist/core/codegraph/reindex.js +218 -0
- package/dist/core/codegraph/resolve-edges.js +107 -0
- package/dist/core/codegraph/watcher.js +440 -0
- package/dist/core/diagnostics/probes/sandbox.js +7 -12
- package/dist/core/engine/prompts.js +32 -0
- package/dist/core/eval/v1/ledger.js +83 -0
- package/dist/core/eval/v1/runner.js +280 -0
- package/dist/core/eval/v1/scoring.js +68 -0
- package/dist/core/eval/v1/task-loader.js +191 -0
- package/dist/core/eval/v1/types.js +14 -0
- package/dist/core/eval/v1/verifier.js +176 -0
- package/dist/core/eval/v1/yaml-parser.js +250 -0
- package/dist/core/sandboxing/adapter.js +31 -17
- package/dist/core/sandboxing/bubblewrap.js +209 -0
- package/dist/core/sandboxing/index.js +32 -3
- package/dist/core/sandboxing/policy.js +97 -0
- package/dist/core/sandboxing/seatbelt.js +69 -21
- package/dist/core/settings.js +31 -7
- package/dist/runtime/cli.js +58 -0
- package/dist/runtime/commands/eval-v1.js +266 -0
- package/dist/runtime/commands/index-cmd.js +125 -19
- package/dist/runtime/commands/servers-cli.js +182 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/bash.js +187 -3
- package/package.json +10 -3
package/dist/runtime/cli.js
CHANGED
|
@@ -73,6 +73,7 @@ import { installDefaultSkills } from '../core/skills/defaults.js';
|
|
|
73
73
|
import { runRememberCommand, runSimplifyCommand, runStuckCommand, runBatchCommand, runVerifyCommand, runLoopCommand, runSkillifyCommand, } from '../skills/bundled/index.js';
|
|
74
74
|
import { runAgentsCommand } from './commands/agents.js';
|
|
75
75
|
import { runIndexCommand } from './commands/index-cmd.js';
|
|
76
|
+
import { runServersCliCommand } from './commands/servers-cli.js';
|
|
76
77
|
import { runLspCommand } from './commands/lsp.js';
|
|
77
78
|
import { runPatchCommand } from './commands/patch.js';
|
|
78
79
|
import { runWorktreeCommand } from './commands/worktree.js';
|
|
@@ -214,6 +215,21 @@ const handlers = {
|
|
|
214
215
|
// scenario. Subcommand-only — no slash counterpart per the Phase 1
|
|
215
216
|
// scope ("no new slash commands; harness is CLI subcommand only").
|
|
216
217
|
smoke: dispatchSmoke,
|
|
218
|
+
// Backlog #120 ( Reviewer foundation): `pugi eval-v1` runs
|
|
219
|
+
// the frozen 20-task benchmark and produces a scalar pugi_score per
|
|
220
|
+
// task plus an aggregate mean. Ledger lives at `eval/v1/results.tsv`
|
|
221
|
+
// (append-only per backlog #110). Real-engine runs are operator-
|
|
222
|
+
// triggered via `--all`; CI wires the same command on a nightly
|
|
223
|
+
// schedule in `.github/workflows/eval-v1.yml`.
|
|
224
|
+
'eval-v1': dispatchEvalV1,
|
|
225
|
+
// PR M (2026-06-05): `pugi servers` top-level CLI mirroring the
|
|
226
|
+
// `/servers` REPL slash. Lists tracked server_start processes and
|
|
227
|
+
// kills them via the SIGTERM ladder. Same primitive
|
|
228
|
+
// (`runServersCommand`) as the slash; this wrapper only owns the
|
|
229
|
+
// argv contract + exit codes (0 / 2 / 3) + the `--workspace`
|
|
230
|
+
// override so the operator can rescue an orphaned server from any
|
|
231
|
+
// shell after the REPL is gone.
|
|
232
|
+
servers: dispatchServers,
|
|
217
233
|
sync,
|
|
218
234
|
style: dispatchStyle,
|
|
219
235
|
// `pugi theme` flips the local TUI color
|
|
@@ -623,6 +639,29 @@ async function dispatchSmoke(args, flags, _session) {
|
|
|
623
639
|
if (rc !== 0)
|
|
624
640
|
process.exitCode = rc;
|
|
625
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* Backlog #120 - `pugi eval-v1` dispatcher.
|
|
644
|
+
*
|
|
645
|
+
* Forwards к `runEvalV1Command`. The handler stays thin: the eval
|
|
646
|
+
* runner owns workspace setup + scoring + ledger; the dispatcher only
|
|
647
|
+
* routes argv + JSON flag + exit code.
|
|
648
|
+
*
|
|
649
|
+
* Exit codes:
|
|
650
|
+
* 0 - every task passed (or `--list` / `--help`).
|
|
651
|
+
* 1 - at least one task failed.
|
|
652
|
+
* 2 - invalid args / task load failure.
|
|
653
|
+
*/
|
|
654
|
+
async function dispatchEvalV1(args, flags, _session) {
|
|
655
|
+
const { runEvalV1Command } = await import('./commands/eval-v1.js');
|
|
656
|
+
const rc = await runEvalV1Command({
|
|
657
|
+
args,
|
|
658
|
+
cwd: process.cwd(),
|
|
659
|
+
json: flags.json,
|
|
660
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
661
|
+
});
|
|
662
|
+
if (rc !== 0)
|
|
663
|
+
process.exitCode = rc;
|
|
664
|
+
}
|
|
626
665
|
/**
|
|
627
666
|
* Backlog — `pugi stuck` top-level dispatcher.
|
|
628
667
|
*
|
|
@@ -975,6 +1014,25 @@ async function dispatchIndex(args, flags, _session) {
|
|
|
975
1014
|
if (rc !== 0)
|
|
976
1015
|
process.exitCode = rc;
|
|
977
1016
|
}
|
|
1017
|
+
/**
|
|
1018
|
+
* PR M (2026-06-05): `pugi servers [stop <target>] [--workspace <path>]`
|
|
1019
|
+
* top-level dispatcher. Thin wrapper around `runServersCliCommand` -
|
|
1020
|
+
* the runner owns argv parsing, IO sink wiring, and the exit-code
|
|
1021
|
+
* mapping. Mirrors the `dispatchIndex` shape.
|
|
1022
|
+
*
|
|
1023
|
+
* Exit-code policy (forwarded from the runner):
|
|
1024
|
+
* 0 - success / empty / stopped / --help
|
|
1025
|
+
* 2 - usage error (unknown flag, missing stop target)
|
|
1026
|
+
* 3 - not-found (stop target did not match)
|
|
1027
|
+
*/
|
|
1028
|
+
async function dispatchServers(args, flags, _session) {
|
|
1029
|
+
const rc = await runServersCliCommand(args, {
|
|
1030
|
+
workspaceRoot: process.cwd(),
|
|
1031
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1032
|
+
});
|
|
1033
|
+
if (rc !== 0)
|
|
1034
|
+
process.exitCode = rc;
|
|
1035
|
+
}
|
|
978
1036
|
/**
|
|
979
1037
|
* — `pugi onboarding` top-level dispatcher.
|
|
980
1038
|
*
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi eval-v1` - run the frozen 20-task benchmark harness (#120).
|
|
3
|
+
*
|
|
4
|
+
* Surface:
|
|
5
|
+
*
|
|
6
|
+
* pugi eval-v1 --list # list task ids + briefs
|
|
7
|
+
* pugi eval-v1 --task 01-snake-html # run one task
|
|
8
|
+
* pugi eval-v1 --task 01,02,03 # run a subset
|
|
9
|
+
* pugi eval-v1 --all # run the full 20
|
|
10
|
+
* pugi eval-v1 --all --model qwen3-coder # pin the engine model
|
|
11
|
+
* pugi eval-v1 --all --json # JSON envelope output
|
|
12
|
+
* pugi eval-v1 --all --no-ledger # skip results.tsv append
|
|
13
|
+
*
|
|
14
|
+
* The handler stays thin: it resolves the tasks directory + pugi
|
|
15
|
+
* binary, calls `runHarness`, appends every result к the ledger
|
|
16
|
+
* (unless `--no-ledger`), and renders either a text table or a JSON
|
|
17
|
+
* envelope.
|
|
18
|
+
*
|
|
19
|
+
* Aggregate scoring (mean across all per-task scores) drives the CI
|
|
20
|
+
* regression gate in `.github/workflows/eval-v1.yml`. The gate is
|
|
21
|
+
* "aggregatePugiScore must not drop more than 10% vs the last week
|
|
22
|
+
* median" - the workflow computes the median itself from the
|
|
23
|
+
* committed ledger; the CLI's only job is к produce a fresh row.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { dirname, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { defaultLedgerPath, defaultTasksDir, loadAllTasks, } from '../../core/eval/v1/task-loader.js';
|
|
30
|
+
import { aggregateScore } from '../../core/eval/v1/scoring.js';
|
|
31
|
+
import { appendLedgerRows } from '../../core/eval/v1/ledger.js';
|
|
32
|
+
import { runHarness } from '../../core/eval/v1/runner.js';
|
|
33
|
+
function parseFlags(args) {
|
|
34
|
+
const out = {
|
|
35
|
+
list: false,
|
|
36
|
+
all: false,
|
|
37
|
+
task: [],
|
|
38
|
+
model: undefined,
|
|
39
|
+
ledger: true,
|
|
40
|
+
tasksDir: undefined,
|
|
41
|
+
ledgerPath: undefined,
|
|
42
|
+
help: false,
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
45
|
+
const arg = args[i] ?? '';
|
|
46
|
+
if (arg === '--list')
|
|
47
|
+
out.list = true;
|
|
48
|
+
else if (arg === '--all')
|
|
49
|
+
out.all = true;
|
|
50
|
+
else if (arg === '--no-ledger')
|
|
51
|
+
out.ledger = false;
|
|
52
|
+
else if (arg === '--help' || arg === '-h')
|
|
53
|
+
out.help = true;
|
|
54
|
+
else if (arg === '--task') {
|
|
55
|
+
const next = args[i + 1];
|
|
56
|
+
if (!next || next.startsWith('--')) {
|
|
57
|
+
throw new Error('--task requires a comma-separated list of ids');
|
|
58
|
+
}
|
|
59
|
+
out.task.push(...next.split(',').map((s) => s.trim()).filter(Boolean));
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
else if (arg.startsWith('--task=')) {
|
|
63
|
+
out.task.push(...arg.slice('--task='.length).split(',').map((s) => s.trim()).filter(Boolean));
|
|
64
|
+
}
|
|
65
|
+
else if (arg === '--model') {
|
|
66
|
+
const next = args[i + 1];
|
|
67
|
+
if (!next)
|
|
68
|
+
throw new Error('--model requires a value');
|
|
69
|
+
out.model = next;
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
else if (arg.startsWith('--model=')) {
|
|
73
|
+
out.model = arg.slice('--model='.length);
|
|
74
|
+
}
|
|
75
|
+
else if (arg === '--tasks-dir') {
|
|
76
|
+
const next = args[i + 1];
|
|
77
|
+
if (!next)
|
|
78
|
+
throw new Error('--tasks-dir requires a path');
|
|
79
|
+
out.tasksDir = next;
|
|
80
|
+
i += 1;
|
|
81
|
+
}
|
|
82
|
+
else if (arg.startsWith('--tasks-dir=')) {
|
|
83
|
+
out.tasksDir = arg.slice('--tasks-dir='.length);
|
|
84
|
+
}
|
|
85
|
+
else if (arg === '--ledger') {
|
|
86
|
+
const next = args[i + 1];
|
|
87
|
+
if (!next)
|
|
88
|
+
throw new Error('--ledger requires a path');
|
|
89
|
+
out.ledgerPath = next;
|
|
90
|
+
i += 1;
|
|
91
|
+
}
|
|
92
|
+
else if (arg.startsWith('--ledger=')) {
|
|
93
|
+
out.ledgerPath = arg.slice('--ledger='.length);
|
|
94
|
+
}
|
|
95
|
+
else if (arg === '--json') {
|
|
96
|
+
// accepted at top-level; ignore here
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
throw new Error(`unknown eval-v1 flag: ${arg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function resolvePackageRoot(override) {
|
|
105
|
+
if (override)
|
|
106
|
+
return resolve(override);
|
|
107
|
+
// The compiled file lives at `dist/runtime/commands/eval-v1.js`;
|
|
108
|
+
// climb three directories to reach the package root in both dev
|
|
109
|
+
// (`src/runtime/commands/eval-v1.ts`) and dist builds.
|
|
110
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
111
|
+
return resolve(here, '..', '..', '..');
|
|
112
|
+
}
|
|
113
|
+
function resolvePugiBin(override, packageRoot) {
|
|
114
|
+
if (override)
|
|
115
|
+
return resolve(override);
|
|
116
|
+
const local = resolve(packageRoot, 'bin', 'run.js');
|
|
117
|
+
if (existsSync(local))
|
|
118
|
+
return local;
|
|
119
|
+
// Fall back к PATH lookup; spawn will pick it up.
|
|
120
|
+
return 'pugi';
|
|
121
|
+
}
|
|
122
|
+
function captureGitSha(packageRoot) {
|
|
123
|
+
try {
|
|
124
|
+
const out = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
125
|
+
cwd: packageRoot,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
128
|
+
});
|
|
129
|
+
const trimmed = out.trim();
|
|
130
|
+
return trimmed === '' ? '(unknown)' : trimmed;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return '(unknown)';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function renderTable(report) {
|
|
137
|
+
const headers = ['id', 'difficulty', 'status', 'score', 'tokens', 'wall_ms', 'verifs'];
|
|
138
|
+
const rows = report.results.map((r) => [
|
|
139
|
+
r.taskId,
|
|
140
|
+
findDifficulty(r.taskId, report) ?? '?',
|
|
141
|
+
r.status,
|
|
142
|
+
r.pugiScore.toFixed(2),
|
|
143
|
+
String(r.tokensUsed),
|
|
144
|
+
String(r.wallClockMs),
|
|
145
|
+
`${r.verifications.filter((v) => v.passed).length}/${r.verifications.length}`,
|
|
146
|
+
]);
|
|
147
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
148
|
+
const renderRow = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ');
|
|
149
|
+
const lines = [renderRow(headers), renderRow(widths.map((w) => '-'.repeat(w)))];
|
|
150
|
+
for (const row of rows)
|
|
151
|
+
lines.push(renderRow(row));
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(`aggregate pugi_score: ${report.aggregatePugiScore.toFixed(2)} (mean of ${report.results.length})`);
|
|
154
|
+
lines.push(`model: ${report.model}`);
|
|
155
|
+
lines.push(`git_sha: ${report.gitSha}`);
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
const difficultyMap = new WeakMap();
|
|
159
|
+
function findDifficulty(taskId, report) {
|
|
160
|
+
const cached = difficultyMap.get(report);
|
|
161
|
+
return cached?.get(taskId);
|
|
162
|
+
}
|
|
163
|
+
function indexDifficulty(report, specs) {
|
|
164
|
+
const map = new Map();
|
|
165
|
+
for (const s of specs)
|
|
166
|
+
map.set(s.id, s.difficulty);
|
|
167
|
+
difficultyMap.set(report, map);
|
|
168
|
+
}
|
|
169
|
+
export async function runEvalV1Command(ctx) {
|
|
170
|
+
let flags;
|
|
171
|
+
try {
|
|
172
|
+
flags = parseFlags(ctx.args);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
ctx.writeOutput({ command: 'eval-v1', status: 'invalid_args', reason: err.message }, `pugi eval-v1: ${err.message}`);
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
if (flags.help) {
|
|
179
|
+
const help = [
|
|
180
|
+
'pugi eval-v1 - frozen 20-task benchmark harness (#120)',
|
|
181
|
+
'',
|
|
182
|
+
'usage:',
|
|
183
|
+
' pugi eval-v1 --list',
|
|
184
|
+
' pugi eval-v1 --task <id[,id...]>',
|
|
185
|
+
' pugi eval-v1 --all [--model <slug>] [--no-ledger] [--json]',
|
|
186
|
+
].join('\n');
|
|
187
|
+
ctx.writeOutput({ command: 'eval-v1', status: 'help', help }, help);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
const packageRoot = resolvePackageRoot(ctx.packageRoot);
|
|
191
|
+
const tasksDir = flags.tasksDir ?? defaultTasksDir(packageRoot);
|
|
192
|
+
const ledgerPath = flags.ledgerPath ?? defaultLedgerPath(packageRoot);
|
|
193
|
+
const log = ctx.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
194
|
+
let loaded;
|
|
195
|
+
try {
|
|
196
|
+
loaded = loadAllTasks(tasksDir);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
ctx.writeOutput({
|
|
200
|
+
command: 'eval-v1',
|
|
201
|
+
status: 'tasks_load_failed',
|
|
202
|
+
reason: err.message,
|
|
203
|
+
}, `pugi eval-v1: ${err.message}`);
|
|
204
|
+
return 2;
|
|
205
|
+
}
|
|
206
|
+
const specs = loaded.map((l) => l.spec);
|
|
207
|
+
if (flags.list) {
|
|
208
|
+
const indexLines = specs.map((s) => `${s.id} [${s.command}/${s.difficulty}] ${s.brief}`);
|
|
209
|
+
const out = indexLines.join('\n');
|
|
210
|
+
ctx.writeOutput({ command: 'eval-v1', status: 'listed', tasks: specs.map((s) => ({
|
|
211
|
+
id: s.id, command: s.command, difficulty: s.difficulty, brief: s.brief,
|
|
212
|
+
})) }, out);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
if (!flags.all && flags.task.length === 0) {
|
|
216
|
+
const reason = 'pass --all OR --task <id[,id...]> OR --list';
|
|
217
|
+
ctx.writeOutput({ command: 'eval-v1', status: 'invalid_args', reason }, `pugi eval-v1: ${reason}`);
|
|
218
|
+
return 2;
|
|
219
|
+
}
|
|
220
|
+
const only = flags.all ? undefined : flags.task;
|
|
221
|
+
if (only) {
|
|
222
|
+
const known = new Set(specs.map((s) => s.id));
|
|
223
|
+
const unknown = only.filter((id) => !known.has(id));
|
|
224
|
+
if (unknown.length) {
|
|
225
|
+
const reason = `unknown task id(s): ${unknown.join(', ')}`;
|
|
226
|
+
ctx.writeOutput({ command: 'eval-v1', status: 'invalid_args', reason }, `pugi eval-v1: ${reason}`);
|
|
227
|
+
return 2;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const pugiBin = resolvePugiBin(ctx.pugiBin, packageRoot);
|
|
231
|
+
const startedAt = new Date().toISOString();
|
|
232
|
+
const gitSha = captureGitSha(packageRoot);
|
|
233
|
+
const results = await runHarness({
|
|
234
|
+
specs,
|
|
235
|
+
options: {
|
|
236
|
+
pugiBin,
|
|
237
|
+
...(flags.model !== undefined ? { model: flags.model } : {}),
|
|
238
|
+
...(only !== undefined ? { only } : {}),
|
|
239
|
+
...(ctx.runner ? { runner: ctx.runner } : {}),
|
|
240
|
+
...(ctx.env !== undefined ? { env: ctx.env } : {}),
|
|
241
|
+
onTaskStart: (s) => log(`[eval-v1] ${s.id} starting (${s.command}/${s.difficulty})`),
|
|
242
|
+
onTaskFinish: (r) => log(`[eval-v1] ${r.taskId} ${r.status} score=${r.pugiScore.toFixed(2)} tokens=${r.tokensUsed} wall=${r.wallClockMs}ms`),
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
const report = {
|
|
246
|
+
schemaVersion: 1,
|
|
247
|
+
startedAt,
|
|
248
|
+
model: flags.model ?? '(default)',
|
|
249
|
+
results,
|
|
250
|
+
aggregatePugiScore: aggregateScore(results),
|
|
251
|
+
gitSha,
|
|
252
|
+
};
|
|
253
|
+
indexDifficulty(report, specs);
|
|
254
|
+
if (flags.ledger && results.length > 0) {
|
|
255
|
+
appendLedgerRows(ledgerPath, results.map((r) => ({
|
|
256
|
+
timestamp: startedAt,
|
|
257
|
+
gitSha,
|
|
258
|
+
model: report.model,
|
|
259
|
+
result: r,
|
|
260
|
+
})));
|
|
261
|
+
}
|
|
262
|
+
ctx.writeOutput(report, renderTable(report));
|
|
263
|
+
const anyFail = results.some((r) => r.status !== 'pass');
|
|
264
|
+
return anyFail ? 1 : 0;
|
|
265
|
+
}
|
|
266
|
+
//# sourceMappingURL=eval-v1.js.map
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { resolve } from 'node:path';
|
|
42
42
|
import { closeIndex, countSymbols, findCallers, findDefinition, openIndex, resolveIndexPath, searchSymbols, } from '../../core/codegraph/db.js';
|
|
43
|
+
import { reindexWorkspace } from '../../core/codegraph/reindex.js';
|
|
44
|
+
import { startWatcher } from '../../core/codegraph/watcher.js';
|
|
43
45
|
import { INDEXED_LANGUAGES } from '../../core/codegraph/types.js';
|
|
44
46
|
/**
|
|
45
47
|
* Single entry-point. Returns the process exit code; the caller in
|
|
@@ -106,7 +108,7 @@ function printHelp(ctx) {
|
|
|
106
108
|
'',
|
|
107
109
|
'Usage:',
|
|
108
110
|
' pugi index Re-index the workspace.',
|
|
109
|
-
' pugi index --watch
|
|
111
|
+
' pugi index --watch Live auto-sync via chokidar (2s debounce).',
|
|
110
112
|
' pugi index search <query> FTS5 search across symbol names.',
|
|
111
113
|
' pugi index definition <symbol> Find the definition of <symbol>.',
|
|
112
114
|
' pugi index callers <symbol> List call sites referencing <symbol>.',
|
|
@@ -121,37 +123,34 @@ function printUnknown(ctx, raw) {
|
|
|
121
123
|
ctx.writeOutput({ command: 'index', error: 'unknown-subcommand', got: raw }, `pugi index: unknown subcommand "${raw}". Allowed: search, definition, callers, --watch, --help.`);
|
|
122
124
|
return 2;
|
|
123
125
|
}
|
|
124
|
-
function runReindex(ctx, watch) {
|
|
126
|
+
async function runReindex(ctx, watch) {
|
|
125
127
|
if (watch) {
|
|
126
|
-
ctx
|
|
127
|
-
command: 'index',
|
|
128
|
-
sub: 'reindex',
|
|
129
|
-
watch: true,
|
|
130
|
-
status: 'not-implemented',
|
|
131
|
-
followup: 'PR L2',
|
|
132
|
-
}, [
|
|
133
|
-
'pugi index --watch: file watcher not implemented in this scaffold PR.',
|
|
134
|
-
'Tracked in follow-up PR L2 (chokidar + 2s debounce + incremental).',
|
|
135
|
-
].join('\n'));
|
|
136
|
-
return 3;
|
|
128
|
+
return runWatch(ctx);
|
|
137
129
|
}
|
|
138
130
|
let db = null;
|
|
139
131
|
try {
|
|
140
132
|
db = openIndex(ctx.workspaceRoot);
|
|
133
|
+
const summary = await reindexWorkspace(db, { quiet: true });
|
|
141
134
|
const symCount = countSymbols(db);
|
|
142
135
|
ctx.writeOutput({
|
|
143
136
|
command: 'index',
|
|
144
137
|
sub: 'reindex',
|
|
145
138
|
watch: false,
|
|
146
|
-
status: '
|
|
147
|
-
followup: 'PR L1',
|
|
139
|
+
status: 'ok',
|
|
148
140
|
dbPath: db.dbPath,
|
|
149
141
|
schemaVersion: db.version,
|
|
150
|
-
|
|
142
|
+
filesScanned: summary.filesScanned,
|
|
143
|
+
filesIndexed: summary.filesIndexed,
|
|
144
|
+
symbolsInserted: summary.symbolsInserted,
|
|
145
|
+
edgesInserted: summary.edgesInserted,
|
|
146
|
+
edgesOrphaned: summary.edgesOrphaned,
|
|
147
|
+
totalSymbols: symCount,
|
|
148
|
+
elapsedMs: summary.totalMs,
|
|
151
149
|
}, [
|
|
152
150
|
`Index DB ready at ${db.dbPath} (schema v${db.version}).`,
|
|
153
|
-
`
|
|
154
|
-
|
|
151
|
+
`Scanned ${summary.filesScanned} files, indexed ${summary.filesIndexed}.`,
|
|
152
|
+
`Symbols inserted: ${summary.symbolsInserted}. Edges inserted: ${summary.edgesInserted} (${summary.edgesOrphaned} orphans dropped).`,
|
|
153
|
+
`Elapsed: ${summary.totalMs} ms. Total symbols on disk: ${symCount}.`,
|
|
155
154
|
].join('\n'));
|
|
156
155
|
return 0;
|
|
157
156
|
}
|
|
@@ -165,11 +164,94 @@ function runReindex(ctx, watch) {
|
|
|
165
164
|
closeIndex(db);
|
|
166
165
|
}
|
|
167
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* `pugi index --watch` runtime: do one full re-index up-front so the
|
|
169
|
+
* index reflects the workspace as it stood at startup, then hand off
|
|
170
|
+
* к the chokidar watcher and block until SIGINT / SIGTERM. The DB
|
|
171
|
+
* handle stays open для the watcher's lifetime - the watcher writes
|
|
172
|
+
* incrementally, the operator quits with Ctrl+C, we close.
|
|
173
|
+
*/
|
|
174
|
+
async function runWatch(ctx) {
|
|
175
|
+
const writeStderr = ctx.writeStderr ?? ((text) => void process.stderr.write(text));
|
|
176
|
+
let db = null;
|
|
177
|
+
try {
|
|
178
|
+
db = openIndex(ctx.workspaceRoot);
|
|
179
|
+
// Baseline reindex so the watcher starts on a known-current index.
|
|
180
|
+
// Reindex is idempotent + cheap on subsequent runs (no-op for
|
|
181
|
+
// unchanged files via the sha256 fingerprint).
|
|
182
|
+
const summary = await reindexWorkspace(db, { quiet: true });
|
|
183
|
+
ctx.writeOutput({
|
|
184
|
+
command: 'index',
|
|
185
|
+
sub: 'reindex',
|
|
186
|
+
watch: true,
|
|
187
|
+
status: 'watching',
|
|
188
|
+
dbPath: db.dbPath,
|
|
189
|
+
schemaVersion: db.version,
|
|
190
|
+
filesScanned: summary.filesScanned,
|
|
191
|
+
filesIndexed: summary.filesIndexed,
|
|
192
|
+
symbolsInserted: summary.symbolsInserted,
|
|
193
|
+
edgesInserted: summary.edgesInserted,
|
|
194
|
+
baselineMs: summary.totalMs,
|
|
195
|
+
}, [
|
|
196
|
+
`Baseline index ready (${summary.filesIndexed} files, ${summary.symbolsInserted} symbols, ${summary.edgesInserted} edges).`,
|
|
197
|
+
`watching ${ctx.workspaceRoot}...`,
|
|
198
|
+
' Press Ctrl+C to stop.',
|
|
199
|
+
].join('\n'));
|
|
200
|
+
const handle = startWatcher({
|
|
201
|
+
db,
|
|
202
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
203
|
+
writeStderr,
|
|
204
|
+
});
|
|
205
|
+
// Set up SIGINT / SIGTERM traps. Resolve the promise that gates
|
|
206
|
+
// `runWatch` once the handle has fully closed.
|
|
207
|
+
return await new Promise((resolvePromise) => {
|
|
208
|
+
let shuttingDown = false;
|
|
209
|
+
const shutdown = async (signal) => {
|
|
210
|
+
if (shuttingDown)
|
|
211
|
+
return;
|
|
212
|
+
shuttingDown = true;
|
|
213
|
+
try {
|
|
214
|
+
await handle.close();
|
|
215
|
+
writeStderr(`pugi index --watch: stopped (${signal}).\n`);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
219
|
+
writeStderr(`pugi index --watch: shutdown error: ${message}\n`);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
if (db)
|
|
223
|
+
closeIndex(db);
|
|
224
|
+
db = null;
|
|
225
|
+
process.off('SIGINT', sigintListener);
|
|
226
|
+
process.off('SIGTERM', sigtermListener);
|
|
227
|
+
resolvePromise(0);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const sigintListener = () => void shutdown('SIGINT');
|
|
231
|
+
const sigtermListener = () => void shutdown('SIGTERM');
|
|
232
|
+
process.on('SIGINT', sigintListener);
|
|
233
|
+
process.on('SIGTERM', sigtermListener);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
ctx.writeOutput({ command: 'index', sub: 'reindex', watch: true, error: 'watcher_failed', message }, `pugi index --watch failed: ${message}`);
|
|
239
|
+
if (db)
|
|
240
|
+
closeIndex(db);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
168
244
|
function runSearch(ctx, query) {
|
|
169
245
|
let db = null;
|
|
170
246
|
try {
|
|
171
247
|
db = openIndex(ctx.workspaceRoot);
|
|
172
|
-
|
|
248
|
+
// Wrap a bare identifier in a prefix-match phrase so operators
|
|
249
|
+
// can type `pugi index search foo` and find `fooBar`. FTS5 will
|
|
250
|
+
// happily accept a more specific raw expression too (e.g.
|
|
251
|
+
// `foo*` or `"exact phrase"`) - we only inject the convenience
|
|
252
|
+
// wrapping when the input looks like a plain identifier.
|
|
253
|
+
const wrapped = wrapSearchQuery(query);
|
|
254
|
+
const results = searchSymbols(db, wrapped, { limit: 50 });
|
|
173
255
|
ctx.writeOutput({
|
|
174
256
|
command: 'index',
|
|
175
257
|
sub: 'search',
|
|
@@ -347,6 +429,30 @@ function renderCallersText(symbol, callers) {
|
|
|
347
429
|
export function resolveWorkspaceRoot(input) {
|
|
348
430
|
return resolve(input);
|
|
349
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Best-effort prefix wrap for FTS5. When the operator types a plain
|
|
434
|
+
* identifier (`fooBar`, `my_func`, `Greeter`), wrap it as a prefix
|
|
435
|
+
* match. When the input already looks like an FTS5 expression
|
|
436
|
+
* (contains `*` / `"` / `:` / leading `+` `-`), pass it through
|
|
437
|
+
* untouched. Exported so the spec can pin the behaviour.
|
|
438
|
+
*
|
|
439
|
+
* `unicode61` (the FTS5 default tokenizer) splits on punctuation
|
|
440
|
+
* other than ASCII letters/digits; an unquoted identifier with
|
|
441
|
+
* `_` или `-` would be tokenised к multiple terms. We avoid that
|
|
442
|
+
* by quoting before appending `*` for prefix-match.
|
|
443
|
+
*/
|
|
444
|
+
export function wrapSearchQuery(query) {
|
|
445
|
+
const trimmed = query.trim();
|
|
446
|
+
if (trimmed.length === 0)
|
|
447
|
+
return trimmed;
|
|
448
|
+
if (/["*:+\-^()]/.test(trimmed))
|
|
449
|
+
return trimmed;
|
|
450
|
+
// Single identifier-like token: quote + prefix.
|
|
451
|
+
if (!/\s/.test(trimmed)) {
|
|
452
|
+
return `"${trimmed}"*`;
|
|
453
|
+
}
|
|
454
|
+
return trimmed;
|
|
455
|
+
}
|
|
350
456
|
// Re-export `resolveIndexPath` from db.ts for callers that want to know
|
|
351
457
|
// where the DB lives without opening it (e.g. `pugi doctor`).
|
|
352
458
|
export { resolveIndexPath };
|