canary-test-cli 6.8.1 → 7.1.0
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/dist/engine/analysis/cli.js +261 -89
- package/dist/engine/analysis/engine.js +39 -21
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/cli-commands.js +251 -43
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +151 -48
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +312 -40
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +6 -6
- package/dist/engine/guardian/agent-tier.js +3 -3
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +15 -1420
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
- package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
- package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
- package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
- package/dist/engine/guardian/diff-coverage/paths.js +164 -0
- package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
- package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
- package/dist/engine/guardian/diff-coverage/types.js +115 -0
- package/dist/engine/guardian/pr-check.js +10 -20
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/engine-checks.d.ts +15 -0
- package/dist/engine-checks.js +92 -1
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/overlay-commands.d.ts +12 -1
- package/dist/overlay-commands.js +28 -2
- package/dist/router.js +17 -5
- package/dist/uninstall-render.d.ts +11 -0
- package/dist/uninstall-render.js +60 -0
- package/dist/uninstall-scan.d.ts +14 -0
- package/dist/uninstall-scan.js +273 -0
- package/dist/uninstall-types.d.ts +46 -0
- package/dist/uninstall-types.js +91 -0
- package/dist/uninstall.d.ts +13 -0
- package/dist/uninstall.js +181 -0
- package/package.json +1 -1
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* default for `_WORKING_DIR` uses `??` to mirror `os.environ.get(k, cwd)`
|
|
35
35
|
* (an explicitly-empty env var stays `""`).
|
|
36
36
|
* - **ensure_ascii.** Hand-built JSON returned to the MCP host is escaped via
|
|
37
|
-
* {@link ensureAscii} (
|
|
37
|
+
* the shared {@link ensureAscii} (`util/ensure-ascii.ts`) so non-ASCII units
|
|
38
38
|
* emit `\uXXXX`, matching Python's default `json.dumps`.
|
|
39
39
|
* - **splitlines.** `context_snippets` uses {@link pySplitlines}, which drops
|
|
40
40
|
* a single trailing-newline empty tail exactly as `str.splitlines()` does.
|
|
@@ -46,12 +46,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
46
46
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
47
47
|
import * as z from 'zod';
|
|
48
48
|
import { DomainScanner } from './core/domain-scanner.js';
|
|
49
|
-
import { detectEnvironment } from './core/environment-detect.js';
|
|
49
|
+
import { detectEnvironment, detectUserLevel, } from './core/environment-detect.js';
|
|
50
|
+
import { effectivePersonaRegistry, personaToDict, resolvePersona, } from './core/persona.js';
|
|
50
51
|
import { CanaryTestExecutor } from './core/executor.js';
|
|
51
52
|
import { FrameworkRegistry } from './core/framework-registry.js';
|
|
52
53
|
import { HarnessMigrator } from './core/migrator.js';
|
|
53
54
|
import { findTestFiles, PatternMatcher } from './core/pattern-matcher.js';
|
|
54
55
|
import { Scaffolder } from './core/scaffolder.js';
|
|
56
|
+
import { ensureAscii } from './util/ensure-ascii.js';
|
|
55
57
|
// ---------------------------------------------------------------------------
|
|
56
58
|
// Module constants (mirror agent/mcp_server.py)
|
|
57
59
|
// ---------------------------------------------------------------------------
|
|
@@ -99,15 +101,6 @@ const MAX_FILE_FUNCTIONS = 20;
|
|
|
99
101
|
// ---------------------------------------------------------------------------
|
|
100
102
|
// Python-compatibility helpers
|
|
101
103
|
// ---------------------------------------------------------------------------
|
|
102
|
-
/**
|
|
103
|
-
* Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default):
|
|
104
|
-
* escape every code point >= 0x80 as `\uXXXX`. Copied per-module, matching the
|
|
105
|
-
* reporter.ts / guardian pattern (the regex range is written with `\u....`
|
|
106
|
-
* escapes so this source stays ASCII).
|
|
107
|
-
*/
|
|
108
|
-
function ensureAscii(json) {
|
|
109
|
-
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
110
|
-
}
|
|
111
104
|
/**
|
|
112
105
|
* Split like Python's `str.splitlines()` for the common line endings: splits on
|
|
113
106
|
* `\r\n` / `\r` / `\n` and drops the single empty tail a trailing separator
|
|
@@ -436,9 +429,37 @@ export function analyzeFileImpl(filePath) {
|
|
|
436
429
|
// Context-aware persona & environment detection (#341): attach the detected
|
|
437
430
|
// BASE_URL, suite type, and SDET-vs-manual user level. The file under
|
|
438
431
|
// analysis is itself an "open file" signal for the user-level heuristic.
|
|
439
|
-
const
|
|
432
|
+
const detected = detectEnvironment(projectRoot, {
|
|
440
433
|
openFiles: [filePath],
|
|
441
|
-
})
|
|
434
|
+
});
|
|
435
|
+
// Personas (#462) — and the consumer #341 never had. Detection shipped and
|
|
436
|
+
// was read by nobody: the level was computed, attached here, and dropped.
|
|
437
|
+
// Resolving it into a persona is what turns the signal into something a
|
|
438
|
+
// skill can consult instead of re-inventing its own audience rules.
|
|
439
|
+
//
|
|
440
|
+
// The persona re-derives the user level WITHOUT the analyzed file, and that
|
|
441
|
+
// is the whole point of the second call. `filePath` is this tool's argument,
|
|
442
|
+
// not an observation of what the caller is working on, so counting it as
|
|
443
|
+
// evidence about the person would let any `.ts` file in any project with a
|
|
444
|
+
// package.json clear the two-independent-signal floor and declare the reader
|
|
445
|
+
// a senior SDET. `environment` above keeps reporting it — that contract is
|
|
446
|
+
// unchanged — and only the persona declines to count it.
|
|
447
|
+
const [personaLevel, personaSignals, personaConfidence] = detectUserLevel(projectRoot, []);
|
|
448
|
+
// Both I/O decisions are made *here* rather than inside the resolver, which
|
|
449
|
+
// is pure by design: reading the environment variable, and loading the
|
|
450
|
+
// registry (shipped personas folded with every overlay's). `CANARY_PERSONA`
|
|
451
|
+
// is the audience-depth axis and is unrelated to `canary doctor --audience`,
|
|
452
|
+
// which tags which overlay checks run.
|
|
453
|
+
const persona = personaToDict(resolvePersona({
|
|
454
|
+
explicit: process.env['CANARY_PERSONA'] ?? null,
|
|
455
|
+
detected: {
|
|
456
|
+
level: personaLevel,
|
|
457
|
+
confidence: personaConfidence,
|
|
458
|
+
signals: personaSignals,
|
|
459
|
+
},
|
|
460
|
+
registry: effectivePersonaRegistry(),
|
|
461
|
+
}));
|
|
462
|
+
const environment = detected.toDict();
|
|
442
463
|
return {
|
|
443
464
|
framework,
|
|
444
465
|
framework_source: frameworkSource,
|
|
@@ -452,6 +473,7 @@ export function analyzeFileImpl(filePath) {
|
|
|
452
473
|
existing_tests: findExistingTests(projectRoot, framework),
|
|
453
474
|
context_snippets: contextSnippets,
|
|
454
475
|
environment,
|
|
476
|
+
persona,
|
|
455
477
|
};
|
|
456
478
|
}
|
|
457
479
|
/** Python: `_write_test_file_impl`. */
|
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
* load/attr failure -> 6) over a dynamic `import()`, which will not resolve a
|
|
11
11
|
* Python module. No Python test exercises the cli/entry execution branches.
|
|
12
12
|
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
13
15
|
import { Command } from 'commander';
|
|
14
16
|
import pc from 'picocolors';
|
|
15
|
-
import {
|
|
17
|
+
import { CliExitError, jsonIndent2, normalizeUsageExit } from './cli-common.js';
|
|
18
|
+
import { gateOutcome } from './core/gate-result.js';
|
|
19
|
+
import { checkExamples, spawnRunner, } from './core/skill-examples.js';
|
|
20
|
+
import { SurfaceFindingKind, checkSurfaces, collectSurfaces, } from './core/skill-surfaces.js';
|
|
16
21
|
import { isExecutableSkillAllowed, resolveCliPath, } from './core/skill-registry.js';
|
|
17
22
|
import { CROSS, EM_DASH } from './main-deps.js';
|
|
18
23
|
function overlayName(skill) {
|
|
@@ -88,19 +93,19 @@ async function runCmd(name, args, opts, deps) {
|
|
|
88
93
|
const skill = deps.makeSkillRegistry().find(name);
|
|
89
94
|
if (skill === null) {
|
|
90
95
|
deps.out(`${pc.red(CROSS)} No skill named ${pc.bold(name)} found.`);
|
|
91
|
-
throw new
|
|
96
|
+
throw new CliExitError(1);
|
|
92
97
|
}
|
|
93
98
|
if (skill.error) {
|
|
94
99
|
deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)}: ${skill.error}`);
|
|
95
|
-
throw new
|
|
100
|
+
throw new CliExitError(2);
|
|
96
101
|
}
|
|
97
102
|
if (!skill.isExecutable) {
|
|
98
103
|
deps.out(pc.yellow(`Skill ${pc.bold(name)} is markdown-only ${EM_DASH} no cli: or entry: field to run.`));
|
|
99
|
-
throw new
|
|
104
|
+
throw new CliExitError(2);
|
|
100
105
|
}
|
|
101
106
|
if (!isExecutableSkillAllowed(opts.allowExecutableSkills ?? false)) {
|
|
102
107
|
deps.out(`${pc.red(CROSS)} Refusing to invoke executable skill in non-interactive context. Pass ${pc.bold('--allow-executable-skills')} to opt in (e.g. in trusted CI configurations).`);
|
|
103
|
-
throw new
|
|
108
|
+
throw new CliExitError(3);
|
|
104
109
|
}
|
|
105
110
|
const forwarded = args;
|
|
106
111
|
if (skill.cli) {
|
|
@@ -110,7 +115,7 @@ async function runCmd(name, args, opts, deps) {
|
|
|
110
115
|
}
|
|
111
116
|
catch (exc) {
|
|
112
117
|
deps.out(`${pc.red(CROSS)} ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
113
|
-
throw new
|
|
118
|
+
throw new CliExitError(4);
|
|
114
119
|
}
|
|
115
120
|
const cmd = target.endsWith('.py') ? [deps.pythonExe(), target] : [target];
|
|
116
121
|
const res = deps.runSubprocess(cmd[0], [...cmd.slice(1), ...forwarded], {
|
|
@@ -120,14 +125,14 @@ async function runCmd(name, args, opts, deps) {
|
|
|
120
125
|
// A spawn failure (missing interpreter/binary) yields status=null; Python's
|
|
121
126
|
// subprocess.run raises FileNotFoundError -> nonzero exit. Map null -> 1, not
|
|
122
127
|
// a silent 0. A normal run passes its real exit code through.
|
|
123
|
-
throw new
|
|
128
|
+
throw new CliExitError(res.status ?? 1);
|
|
124
129
|
}
|
|
125
130
|
// entry: branch -- see module docstring (Python-module semantics are not
|
|
126
131
|
// portable; the exit-code ladder is preserved).
|
|
127
132
|
const [moduleName, sep, attr] = partition(skill.entry, ':');
|
|
128
133
|
if (!moduleName || !attr || !sep) {
|
|
129
134
|
deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)} entry must be 'module:callable', got '${skill.entry}'`);
|
|
130
|
-
throw new
|
|
135
|
+
throw new CliExitError(5);
|
|
131
136
|
}
|
|
132
137
|
try {
|
|
133
138
|
const mod = (await import(moduleName));
|
|
@@ -135,15 +140,122 @@ async function runCmd(name, args, opts, deps) {
|
|
|
135
140
|
if (typeof target !== 'function')
|
|
136
141
|
throw new Error('not callable');
|
|
137
142
|
const rc = target();
|
|
138
|
-
throw new
|
|
143
|
+
throw new CliExitError(typeof rc === 'number' ? rc : 0);
|
|
139
144
|
}
|
|
140
145
|
catch (exc) {
|
|
141
|
-
if (exc instanceof
|
|
146
|
+
if (exc instanceof CliExitError)
|
|
142
147
|
throw exc;
|
|
143
148
|
deps.out(`${pc.red(CROSS)} Skill ${pc.bold(name)} entry '${skill.entry}': ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
144
|
-
throw new
|
|
149
|
+
throw new CliExitError(6);
|
|
145
150
|
}
|
|
146
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* `skills verify` -- cross-surface consistency (#452) plus execution of the
|
|
154
|
+
* commands the docs promise (#487).
|
|
155
|
+
*
|
|
156
|
+
* **Two checks, two summary lines, deliberately.** Folding them into one
|
|
157
|
+
* denominator is the trap: 80 surfaces checked and 0 examples executed would
|
|
158
|
+
* render as a single healthy number while the half that actually executes
|
|
159
|
+
* something had abstained. Each check therefore reports its own denominator
|
|
160
|
+
* through `gateOutcome`, and the reader sees both.
|
|
161
|
+
*
|
|
162
|
+
* Classified **advisory** (exit 0): both checks are landing on a corpus nothing
|
|
163
|
+
* has ever swept, so their precision is unknown and ADR 0010 says promotion
|
|
164
|
+
* waits for the triage. Abstention is still printed loudly -- that is what the
|
|
165
|
+
* classification does *not* soften.
|
|
166
|
+
*/
|
|
167
|
+
/**
|
|
168
|
+
* Run the examples half, or explain why it could not run.
|
|
169
|
+
*
|
|
170
|
+
* The examples half spawns a CLI. When there is no built one, the honest result
|
|
171
|
+
* is a zero denominator carrying the reason -- an unrunnable runner must never
|
|
172
|
+
* look like a clean corpus.
|
|
173
|
+
*/
|
|
174
|
+
function exampleHalf(opts, surfaces, root) {
|
|
175
|
+
const bin = opts.canaryBin ?? join(root, 'ts', 'bin', 'canary.js');
|
|
176
|
+
if ((opts.runExamples ?? true) && existsSync(bin)) {
|
|
177
|
+
return checkExamples(surfaces, spawnRunner(bin), root);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
checked: 0,
|
|
181
|
+
findings: [],
|
|
182
|
+
skipped: [
|
|
183
|
+
{
|
|
184
|
+
name: 'every documented example',
|
|
185
|
+
reason: opts.runExamples === false
|
|
186
|
+
? '--no-run-examples was passed'
|
|
187
|
+
: `no built CLI at ${bin} (run \`npm --prefix ts run build\`)`,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Per-kind tally over the FULL enum, including kinds that scored zero.
|
|
194
|
+
*
|
|
195
|
+
* A triage backlog is read by category, and `cli-not-executable=0` is the line
|
|
196
|
+
* that says the rule ran. An absent line is indistinguishable from a rule that
|
|
197
|
+
* was never evaluated -- the reading trap this whole check exists to close.
|
|
198
|
+
*/
|
|
199
|
+
function tallyByKind(findings) {
|
|
200
|
+
return Object.values(SurfaceFindingKind)
|
|
201
|
+
.map((kind) => `${kind}=${findings.filter((f) => f.kind === kind).length}`)
|
|
202
|
+
.join(' ');
|
|
203
|
+
}
|
|
204
|
+
/** Both halves as JSON, each carrying its own `checked` and `abstained`. */
|
|
205
|
+
function verifyJson(root, surfaceResult, exampleResult) {
|
|
206
|
+
return jsonIndent2({
|
|
207
|
+
root,
|
|
208
|
+
surfaces: {
|
|
209
|
+
checked: surfaceResult.checked,
|
|
210
|
+
abstained: !(surfaceResult.checked > 0),
|
|
211
|
+
findings: surfaceResult.findings,
|
|
212
|
+
},
|
|
213
|
+
examples: {
|
|
214
|
+
checked: exampleResult.checked,
|
|
215
|
+
abstained: !(exampleResult.checked > 0),
|
|
216
|
+
findings: exampleResult.findings,
|
|
217
|
+
skipped: exampleResult.skipped ?? [],
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** One summary line per half, plus the remediation text on an abstention. */
|
|
222
|
+
function verifyReport(root, surfaceResult, exampleResult, deps) {
|
|
223
|
+
for (const f of surfaceResult.findings) {
|
|
224
|
+
deps.err(`${pc.yellow(CROSS)} [${f.kind}] ${f.name}: ${f.detail}`);
|
|
225
|
+
}
|
|
226
|
+
for (const f of exampleResult.findings) {
|
|
227
|
+
deps.err(`${pc.yellow(CROSS)} [${f.kind}] ${f.skill}: ${f.detail}`);
|
|
228
|
+
}
|
|
229
|
+
const surfaceOutcome = gateOutcome(surfaceResult, 'advisory', {
|
|
230
|
+
noun: 'surface declaration(s)',
|
|
231
|
+
});
|
|
232
|
+
deps.out(`surfaces: ${surfaceOutcome.summaryLine}`);
|
|
233
|
+
deps.out(` by kind: ${tallyByKind(surfaceResult.findings)}`);
|
|
234
|
+
if (surfaceOutcome.abstained) {
|
|
235
|
+
deps.out(` no skill surface was found under ${root} ${EM_DASH} check the path, ` +
|
|
236
|
+
'or that `agents/skills/` has not been renamed.');
|
|
237
|
+
}
|
|
238
|
+
const exampleOutcome = gateOutcome(exampleResult, 'advisory', {
|
|
239
|
+
noun: 'documented example(s)',
|
|
240
|
+
});
|
|
241
|
+
deps.out(`examples: ${exampleOutcome.summaryLine}`);
|
|
242
|
+
if (exampleOutcome.abstained) {
|
|
243
|
+
deps.out(` zero documented commands were executed ${EM_DASH} nothing here is ` +
|
|
244
|
+
'proven. Add a help-shaped example in a shell fence to a SKILL.md, or ' +
|
|
245
|
+
'build the engine so the runner has a CLI to spawn.');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function verifyCmd(opts, deps) {
|
|
249
|
+
const root = resolve(opts.root ?? deps.cwd());
|
|
250
|
+
const surfaces = collectSurfaces(root);
|
|
251
|
+
const surfaceResult = checkSurfaces(root);
|
|
252
|
+
const exampleResult = exampleHalf(opts, surfaces, root);
|
|
253
|
+
if (opts.json) {
|
|
254
|
+
deps.out(verifyJson(root, surfaceResult, exampleResult));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
verifyReport(root, surfaceResult, exampleResult, deps);
|
|
258
|
+
}
|
|
147
259
|
/** Python `str.partition(sep)` -> `[before, sep, after]`. */
|
|
148
260
|
function partition(s, sep) {
|
|
149
261
|
const i = s.indexOf(sep);
|
|
@@ -173,6 +285,16 @@ export function buildSkillsCommand(deps) {
|
|
|
173
285
|
.action(async (name, args, opts) => {
|
|
174
286
|
await runCmd(name, args ?? [], opts, deps);
|
|
175
287
|
});
|
|
288
|
+
program
|
|
289
|
+
.command('verify')
|
|
290
|
+
.description('Check that skill surfaces agree and that documented examples still run.')
|
|
291
|
+
.option('--root <path>', 'Repository root to inspect (default: cwd).')
|
|
292
|
+
.option('--json', 'Emit both denominators and every finding as JSON.')
|
|
293
|
+
.option('--no-run-examples', 'Inspect declarations only; do not execute documented examples.')
|
|
294
|
+
.option('--canary-bin <path>', 'CLI to spawn for documented examples.')
|
|
295
|
+
.action((opts) => {
|
|
296
|
+
verifyCmd(opts, deps);
|
|
297
|
+
});
|
|
176
298
|
for (const sub of program.commands) {
|
|
177
299
|
sub.exitOverride(normalizeUsageExit);
|
|
178
300
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one `ensure_ascii=True` implementation (#710).
|
|
3
|
+
*
|
|
4
|
+
* Python's `json.dumps` escapes every non-ASCII character to `\uXXXX` by
|
|
5
|
+
* default; `JSON.stringify` emits raw UTF-8. Every module that reproduces a
|
|
6
|
+
* Python payload byte-for-byte therefore has to post-process its stringify
|
|
7
|
+
* output, and eight of them used to do it with a private copy of this function.
|
|
8
|
+
*
|
|
9
|
+
* It lives under `util/` rather than in `cli-common.ts` (which documented it
|
|
10
|
+
* first) because the copies were spread across the `core`, `guardian`, and
|
|
11
|
+
* entry layers, and the layer model in `harness.config.json` lets nothing
|
|
12
|
+
* depend on `cli`. `util` is the one leaf every layer is allowed to reach.
|
|
13
|
+
*
|
|
14
|
+
* `ts/test/shared-helper-single-source.test.ts` fails if a ninth copy appears.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Escape every non-ASCII UTF-16 code UNIT to `\uXXXX`, matching Python
|
|
18
|
+
* `json.dumps(ensure_ascii=True)`.
|
|
19
|
+
*
|
|
20
|
+
* Iterating by code unit rather than code point is the load-bearing detail: an
|
|
21
|
+
* astral character's surrogate pair emits `\udXXX\udXXX`, exactly as CPython
|
|
22
|
+
* writes it. A code-point walk (`for (const ch of json)`) would instead reach
|
|
23
|
+
* a value above `0xffff`, which does not fit a four-hex-digit `\uXXXX` escape
|
|
24
|
+
* at all -- so it would either truncate or leave the character raw.
|
|
25
|
+
*
|
|
26
|
+
* Only the `>= 0x80` range is touched, so the ASCII escapes `JSON.stringify`
|
|
27
|
+
* already produced (`\"`, `\\`, control characters) pass through intact.
|
|
28
|
+
*/
|
|
29
|
+
export function ensureAscii(json) {
|
|
30
|
+
let out = '';
|
|
31
|
+
for (let i = 0; i < json.length; i++) {
|
|
32
|
+
const c = json.charCodeAt(i);
|
|
33
|
+
out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=ensure-ascii.js.map
|
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { Command } from 'commander';
|
|
14
14
|
import pc from 'picocolors';
|
|
15
|
-
import {
|
|
15
|
+
import { CliExitError, jsonIndent2, normalizeUsageExit } from './cli-common.js';
|
|
16
16
|
import { SemanticRole, WorkflowDiscoveryError, WorkflowMapping, } from './core/workflow-discovery.js';
|
|
17
17
|
import { CHECK, CROSS, MAGNIFIER, WARN, ELLIPSIS, } from './main-deps.js';
|
|
18
18
|
/** ISO-8601 UTC timestamp truncated to seconds (Python `isoformat(timespec)`). */
|
|
@@ -38,7 +38,7 @@ async function discoverCmd(opts, deps) {
|
|
|
38
38
|
}
|
|
39
39
|
if (keys.length === 0) {
|
|
40
40
|
deps.out(`${pc.yellow('No project keys found.')} Pass ${pc.bold('--project <key>')} or add keys to ${pc.bold('.canary/company.json')} ${'\u{2192}'} ${pc.bold('jira_projects')}.`);
|
|
41
|
-
throw new
|
|
41
|
+
throw new CliExitError(1);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
const errors = [];
|
|
@@ -76,7 +76,7 @@ async function discoverCmd(opts, deps) {
|
|
|
76
76
|
}
|
|
77
77
|
if (errors.length) {
|
|
78
78
|
deps.out(`\n${pc.red(`Discovery failed for: ${errors.join(', ')}`)}`);
|
|
79
|
-
throw new
|
|
79
|
+
throw new CliExitError(1);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
function showCmd(opts, deps) {
|
|
@@ -99,7 +99,7 @@ function showCmd(opts, deps) {
|
|
|
99
99
|
}
|
|
100
100
|
if (keys.length === 0) {
|
|
101
101
|
deps.out(pc.yellow('No cached workflow mappings found.'));
|
|
102
|
-
throw new
|
|
102
|
+
throw new CliExitError(0);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
let anyFound = false;
|
|
@@ -162,7 +162,7 @@ function showCmd(opts, deps) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
if (!anyFound) {
|
|
165
|
-
throw new
|
|
165
|
+
throw new CliExitError(1);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
function initCmd(opts, deps) {
|
|
@@ -170,7 +170,7 @@ function initCmd(opts, deps) {
|
|
|
170
170
|
const mappingPath = wd.mappingPath(opts.project);
|
|
171
171
|
if (existsSync(mappingPath) && !opts.force) {
|
|
172
172
|
deps.out(`${pc.yellow(WARN)} Mapping already exists at ${mappingPath}.\nUse ${pc.bold('--force')} to overwrite.`);
|
|
173
|
-
throw new
|
|
173
|
+
throw new CliExitError(1);
|
|
174
174
|
}
|
|
175
175
|
const resolvedUrl = (opts.atlassianUrl || deps.env['ATLASSIAN_URL'] || '').replace(/\/+$/, '') || null;
|
|
176
176
|
const semanticRoles = {
|
package/dist/engine-checks.d.ts
CHANGED
|
@@ -20,6 +20,21 @@ export declare function parseRegistryVersion(rawBody: string): string | null;
|
|
|
20
20
|
export declare function isOlder(a: string, b: string): boolean;
|
|
21
21
|
/** CLI version vs latest release. Offline degrades to info, never a failure. */
|
|
22
22
|
export declare function checkVersion(deps?: EngineCheckDeps): Promise<CheckResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Installed Claude Code plugin vs the marketplace clone (#522).
|
|
25
|
+
*
|
|
26
|
+
* Canary ships through two independent version streams and only the CLI had
|
|
27
|
+
* staleness detection, so `canary upgrade` would report everything current
|
|
28
|
+
* while leaving the plugin — the way Canary is actually used in-editor —
|
|
29
|
+
* arbitrarily far behind. A real consuming project ran 4.0.0 for six weeks
|
|
30
|
+
* against a 6.x CLI, during which six of Canary's agents did not exist in the
|
|
31
|
+
* version being loaded, with nothing anywhere surfacing it.
|
|
32
|
+
*
|
|
33
|
+
* Purely local file reads: no network, so this cannot fail offline. Both files
|
|
34
|
+
* are untrusted input — a hand-edited manifest degrades to info or skip rather
|
|
35
|
+
* than throwing out of `doctor`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function checkPluginVersion(deps?: EngineCheckDeps): CheckResult;
|
|
23
38
|
/** git present on PATH. */
|
|
24
39
|
export declare function checkGit(deps?: EngineCheckDeps): CheckResult;
|
|
25
40
|
/** Registered overlays present, fresh, and free of local modifications. */
|
package/dist/engine-checks.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.parseRegistryVersion = parseRegistryVersion;
|
|
37
37
|
exports.isOlder = isOlder;
|
|
38
38
|
exports.checkVersion = checkVersion;
|
|
39
|
+
exports.checkPluginVersion = checkPluginVersion;
|
|
39
40
|
exports.checkGit = checkGit;
|
|
40
41
|
exports.checkOverlays = checkOverlays;
|
|
41
42
|
exports.checkProjectConfig = checkProjectConfig;
|
|
@@ -160,6 +161,92 @@ async function checkVersion(deps = {}) {
|
|
|
160
161
|
label: `CLI ${current} (latest)`,
|
|
161
162
|
};
|
|
162
163
|
}
|
|
164
|
+
/** Marketplace that carries the Claude Code plugin, and the plugin's key in it. */
|
|
165
|
+
const MARKETPLACE = 'bop-clocktower';
|
|
166
|
+
const PLUGIN_KEY = `canary@${MARKETPLACE}`;
|
|
167
|
+
/** Parse JSON from disk; null on missing file, unreadable path, or bad syntax. */
|
|
168
|
+
function readJson(file) {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Newest version among the installs recorded for {@link PLUGIN_KEY}.
|
|
178
|
+
*
|
|
179
|
+
* Claude Code stores an array per plugin key because user- and project-scoped
|
|
180
|
+
* installs coexist. Taking the first would report a stale plugin whenever the
|
|
181
|
+
* older scope happened to be listed first, so take the newest and let the
|
|
182
|
+
* comparison speak for the one actually in charge.
|
|
183
|
+
*/
|
|
184
|
+
function installedPluginVersion(manifest) {
|
|
185
|
+
const plugins = manifest
|
|
186
|
+
?.plugins;
|
|
187
|
+
const entries = plugins?.[PLUGIN_KEY];
|
|
188
|
+
if (!Array.isArray(entries))
|
|
189
|
+
return null;
|
|
190
|
+
const versions = entries
|
|
191
|
+
.map((e) => e?.version)
|
|
192
|
+
.filter((v) => typeof v === 'string');
|
|
193
|
+
if (versions.length === 0)
|
|
194
|
+
return null;
|
|
195
|
+
return versions.reduce((best, v) => (isOlder(best, v) ? v : best));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Installed Claude Code plugin vs the marketplace clone (#522).
|
|
199
|
+
*
|
|
200
|
+
* Canary ships through two independent version streams and only the CLI had
|
|
201
|
+
* staleness detection, so `canary upgrade` would report everything current
|
|
202
|
+
* while leaving the plugin — the way Canary is actually used in-editor —
|
|
203
|
+
* arbitrarily far behind. A real consuming project ran 4.0.0 for six weeks
|
|
204
|
+
* against a 6.x CLI, during which six of Canary's agents did not exist in the
|
|
205
|
+
* version being loaded, with nothing anywhere surfacing it.
|
|
206
|
+
*
|
|
207
|
+
* Purely local file reads: no network, so this cannot fail offline. Both files
|
|
208
|
+
* are untrusted input — a hand-edited manifest degrades to info or skip rather
|
|
209
|
+
* than throwing out of `doctor`.
|
|
210
|
+
*/
|
|
211
|
+
function checkPluginVersion(deps = {}) {
|
|
212
|
+
const home = deps.homeDir ?? os.homedir();
|
|
213
|
+
const pluginsDir = path.join(home, '.claude', 'plugins');
|
|
214
|
+
const installed = installedPluginVersion(readJson(path.join(pluginsDir, 'installed_plugins.json')));
|
|
215
|
+
if (!installed) {
|
|
216
|
+
// CLI-only users have no plugin to be stale; not a finding.
|
|
217
|
+
return {
|
|
218
|
+
id: 'engine:plugin-version',
|
|
219
|
+
status: 'skip',
|
|
220
|
+
label: 'Claude Code plugin: not installed',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const marketplace = readJson(path.join(pluginsDir, 'marketplaces', MARKETPLACE, '.claude-plugin', 'plugin.json'))?.version;
|
|
224
|
+
if (typeof marketplace !== 'string') {
|
|
225
|
+
return {
|
|
226
|
+
id: 'engine:plugin-version',
|
|
227
|
+
status: 'info',
|
|
228
|
+
label: `Claude Code plugin ${installed} (could not read the ${MARKETPLACE} marketplace to compare)`,
|
|
229
|
+
remedy: `Refresh it with: /plugin marketplace update ${MARKETPLACE}`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (isOlder(installed, marketplace)) {
|
|
233
|
+
return {
|
|
234
|
+
id: 'engine:plugin-version',
|
|
235
|
+
status: 'fail',
|
|
236
|
+
label: `Claude Code plugin ${installed} is behind marketplace ${marketplace}`,
|
|
237
|
+
// Both commands, in order, always. `/plugin marketplace update` refreshes
|
|
238
|
+
// the clone but leaves the cached plugin in place, and `/plugin install`
|
|
239
|
+
// without it reinstalls whatever the stale clone holds — each alone
|
|
240
|
+
// reports success and changes nothing the user cares about.
|
|
241
|
+
remedy: `Update both — either alone is a no-op:\n /plugin marketplace update ${MARKETPLACE}\n /plugin install ${PLUGIN_KEY}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
id: 'engine:plugin-version',
|
|
246
|
+
status: 'pass',
|
|
247
|
+
label: `Claude Code plugin ${installed} (marketplace ${marketplace})`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
163
250
|
/** git present on PATH. */
|
|
164
251
|
function checkGit(deps = {}) {
|
|
165
252
|
const git = deps.git ?? realGit;
|
|
@@ -245,7 +332,10 @@ function checkOverlays(deps = {}) {
|
|
|
245
332
|
id: `overlay:${o.name}:clean`,
|
|
246
333
|
status: 'fail',
|
|
247
334
|
label: `overlay "${o.name}": ${clean === 'dirty' ? 'local modifications' : 'git status unreadable'}`,
|
|
248
|
-
|
|
335
|
+
// `--force` is part of the remedy on purpose: since #675 a plain
|
|
336
|
+
// `overlay remove` refuses on a dirty clone, so the old advice was a
|
|
337
|
+
// dead end for exactly the user this check fires on.
|
|
338
|
+
remedy: `Commit/stash changes in ${o.path}, or discard them with canary overlay remove ${o.name} --force and re-add.`,
|
|
249
339
|
});
|
|
250
340
|
}
|
|
251
341
|
}
|
|
@@ -527,6 +617,7 @@ function checkSkillRequirements(deps = {}) {
|
|
|
527
617
|
async function runEngineChecks(deps = {}) {
|
|
528
618
|
return [
|
|
529
619
|
await checkVersion(deps),
|
|
620
|
+
checkPluginVersion(deps),
|
|
530
621
|
checkGit(deps),
|
|
531
622
|
...checkOverlays(deps),
|
|
532
623
|
checkOverlayConflicts(deps),
|
package/dist/gate-result.d.ts
CHANGED
|
@@ -48,6 +48,17 @@ export interface GateOutcomeOptions {
|
|
|
48
48
|
/** Unit noun for the clean-pass line. Default: `'check(s)'`. */
|
|
49
49
|
noun?: string;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The errno code `e` carries, or `null` when it carries none.
|
|
53
|
+
*
|
|
54
|
+
* Load-bearing wherever a read failure is absorbed into a {@link SkipEntry}:
|
|
55
|
+
* only "the filesystem said no" may become a skip. Node PROGRAMMER errors carry
|
|
56
|
+
* a string `code` too, so keying off `typeof code === 'string'` reports a
|
|
57
|
+
* genuine defect inside the scanner as a tidy abstention with a misleading
|
|
58
|
+
* reason -- which is how a scanner learns to go quiet. Anything this returns
|
|
59
|
+
* `null` for must still throw.
|
|
60
|
+
*/
|
|
61
|
+
export declare function errnoCode(e: unknown): string | null;
|
|
51
62
|
/**
|
|
52
63
|
* D7: skipped entries render in EVERY summary line.
|
|
53
64
|
*
|
package/dist/gate-result.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// `npm test` verifies this copy has not drifted (--check runs as pretest).
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.EXIT_ABSTAINED = void 0;
|
|
10
|
+
exports.errnoCode = errnoCode;
|
|
10
11
|
exports.skippedSuffix = skippedSuffix;
|
|
11
12
|
exports.gateOutcome = gateOutcome;
|
|
12
13
|
/**
|
|
@@ -19,6 +20,23 @@ const EMDASH = '\u{2014}'; // em dash
|
|
|
19
20
|
// C0 controls (incl. \n, ESC) and DEL: a skip name must never be able to
|
|
20
21
|
// forge output lines or smuggle ANSI sequences into the summary.
|
|
21
22
|
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
23
|
+
// A libuv/POSIX errno code (`ENOENT`, `EACCES`, `EISDIR`), as opposed to a Node
|
|
24
|
+
// programmer-error code (`ERR_INVALID_ARG_TYPE`).
|
|
25
|
+
const ERRNO = /^E[A-Z0-9]+$/;
|
|
26
|
+
/**
|
|
27
|
+
* The errno code `e` carries, or `null` when it carries none.
|
|
28
|
+
*
|
|
29
|
+
* Load-bearing wherever a read failure is absorbed into a {@link SkipEntry}:
|
|
30
|
+
* only "the filesystem said no" may become a skip. Node PROGRAMMER errors carry
|
|
31
|
+
* a string `code` too, so keying off `typeof code === 'string'` reports a
|
|
32
|
+
* genuine defect inside the scanner as a tidy abstention with a misleading
|
|
33
|
+
* reason -- which is how a scanner learns to go quiet. Anything this returns
|
|
34
|
+
* `null` for must still throw.
|
|
35
|
+
*/
|
|
36
|
+
function errnoCode(e) {
|
|
37
|
+
const code = e?.code;
|
|
38
|
+
return typeof code === 'string' && ERRNO.test(code) ? code : null;
|
|
39
|
+
}
|
|
22
40
|
/**
|
|
23
41
|
* D7: skipped entries render in EVERY summary line.
|
|
24
42
|
*
|
|
@@ -72,8 +72,19 @@ export declare function lint(nameOrPath: string | undefined, deps?: CommandDeps,
|
|
|
72
72
|
* updates all; refuses on local modifications or a non-fast-forward.
|
|
73
73
|
*/
|
|
74
74
|
export declare function update(name: string | null, deps?: CommandDeps): number;
|
|
75
|
+
/** Options for {@link remove}. */
|
|
76
|
+
export interface RemoveOptions {
|
|
77
|
+
/** `--force`: delete even when the clone has local modifications (#675). */
|
|
78
|
+
force?: boolean;
|
|
79
|
+
}
|
|
75
80
|
/**
|
|
76
81
|
* `canary overlay remove <name>` — deregister an overlay and delete its clone.
|
|
77
82
|
* Unknown name is an error; the registry is left unchanged in that case.
|
|
83
|
+
*
|
|
84
|
+
* Removal is destructive and irreversible, so it takes the same posture as
|
|
85
|
+
* `overlay update` (#675): a clone with local modifications — or one whose git
|
|
86
|
+
* status cannot be read, which is no safer to delete blind — is refused, and
|
|
87
|
+
* `--force` is the explicit opt-in. A clone that is already gone is not a
|
|
88
|
+
* refusal: there is nothing left to lose, so the stale registry row is dropped.
|
|
78
89
|
*/
|
|
79
|
-
export declare function remove(name: string, deps?: CommandDeps): number;
|
|
90
|
+
export declare function remove(name: string, deps?: CommandDeps, opts?: RemoveOptions): number;
|
package/dist/overlay-commands.js
CHANGED
|
@@ -446,7 +446,10 @@ function updateOne(o, git, out, err) {
|
|
|
446
446
|
}
|
|
447
447
|
if (clean === 'dirty') {
|
|
448
448
|
err.write(`overlay "${o.name}": local modifications in ${o.path} — refusing to update. ` +
|
|
449
|
-
`Commit/stash them, or
|
|
449
|
+
`Commit/stash them, or discard them with ` +
|
|
450
|
+
`'canary overlay remove ${o.name} --force' and re-add. ` +
|
|
451
|
+
`(Plain 'overlay remove' refuses on a dirty clone too, so it will not ` +
|
|
452
|
+
`throw the edits away by accident.)\n`);
|
|
450
453
|
return 1;
|
|
451
454
|
}
|
|
452
455
|
if (o.ref) {
|
|
@@ -520,8 +523,15 @@ function update(name, deps = {}) {
|
|
|
520
523
|
/**
|
|
521
524
|
* `canary overlay remove <name>` — deregister an overlay and delete its clone.
|
|
522
525
|
* Unknown name is an error; the registry is left unchanged in that case.
|
|
526
|
+
*
|
|
527
|
+
* Removal is destructive and irreversible, so it takes the same posture as
|
|
528
|
+
* `overlay update` (#675): a clone with local modifications — or one whose git
|
|
529
|
+
* status cannot be read, which is no safer to delete blind — is refused, and
|
|
530
|
+
* `--force` is the explicit opt-in. A clone that is already gone is not a
|
|
531
|
+
* refusal: there is nothing left to lose, so the stale registry row is dropped.
|
|
523
532
|
*/
|
|
524
|
-
function remove(name, deps = {}) {
|
|
533
|
+
function remove(name, deps = {}, opts = {}) {
|
|
534
|
+
const git = deps.git ?? defaultGit;
|
|
525
535
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
526
536
|
const out = deps.out ?? process.stdout;
|
|
527
537
|
const err = deps.err ?? process.stderr;
|
|
@@ -538,6 +548,22 @@ function remove(name, deps = {}) {
|
|
|
538
548
|
err.write(`canary overlay remove: no overlay named "${name}".\n`);
|
|
539
549
|
return 1;
|
|
540
550
|
}
|
|
551
|
+
if (!opts.force && fs.existsSync(entry.path)) {
|
|
552
|
+
const clean = workingTreeStatus(entry.path, git);
|
|
553
|
+
if (clean === 'unreadable') {
|
|
554
|
+
err.write(`canary overlay remove: cannot read git status at ${entry.path} — ` +
|
|
555
|
+
`refusing to delete it. Inspect the directory, then re-run with ` +
|
|
556
|
+
`--force to remove it anyway.\n`);
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
if (clean === 'dirty') {
|
|
560
|
+
err.write(`canary overlay remove: local modifications in ${entry.path} — ` +
|
|
561
|
+
`refusing to delete them. Commit or discard the changes ` +
|
|
562
|
+
`(git -C ${entry.path} status), then re-run; or re-run with ` +
|
|
563
|
+
`--force to delete the clone and the changes.\n`);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
541
567
|
fs.rmSync(entry.path, { recursive: true, force: true });
|
|
542
568
|
const { registry: next } = registry.remove(reg, name);
|
|
543
569
|
registry.write(next, homeDir);
|