canary-test-cli 7.0.0 → 7.2.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/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- 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 +9 -17
- 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/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- 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 +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- package/package.json +3 -2
|
@@ -16,7 +16,7 @@ export { round1 };
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
// Flaky report
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
export function
|
|
19
|
+
export function buildFlakyTestsReport(rows, windowRuns, minRatePct, limit = 20) {
|
|
20
20
|
if (rows.length === 0) {
|
|
21
21
|
return `No tests above ${pyFloat(minRatePct)}% flake rate in the last ${windowRuns} runs.\n`;
|
|
22
22
|
}
|
|
@@ -75,7 +75,7 @@ function detectSpikes(rows, deltaPp) {
|
|
|
75
75
|
}
|
|
76
76
|
return spikes;
|
|
77
77
|
}
|
|
78
|
-
export function
|
|
78
|
+
export function buildFailureSpikesReport(rows, deltaPp) {
|
|
79
79
|
if (rows.length === 0) {
|
|
80
80
|
return 'No run data available for spike detection.\n';
|
|
81
81
|
}
|
|
@@ -210,8 +210,9 @@ function regressionRow(r) {
|
|
|
210
210
|
export function buildDigest(args) {
|
|
211
211
|
const sections = [
|
|
212
212
|
'# Fleet Health Digest\n',
|
|
213
|
-
'## Flaky Tests\n\n' +
|
|
214
|
-
|
|
213
|
+
'## Flaky Tests\n\n' +
|
|
214
|
+
buildFlakyTestsReport(args.flaky, args.windowRuns, 10.0),
|
|
215
|
+
'## Spikes\n\n' + buildFailureSpikesReport(args.spikes, args.deltaPp),
|
|
215
216
|
'## Area Health\n\n' + buildAreaHealthReport(args.areaHealth, args.weeks),
|
|
216
217
|
'## Common Failures\n\n' +
|
|
217
218
|
buildCommonFailuresReport(args.commonFailures, args.minSuites),
|
|
@@ -5,21 +5,25 @@
|
|
|
5
5
|
* doctor, ticket-update). The commander wiring (options/args/defaults) lives in
|
|
6
6
|
* `cli.ts`; these functions are the thin handlers it dispatches to.
|
|
7
7
|
*
|
|
8
|
-
* Conventions mirror `guardian/cli.ts`: `
|
|
8
|
+
* Conventions mirror `guardian/cli.ts`: `CliExitError` for business exits,
|
|
9
9
|
* `jsonIndent2` for `json.dumps(indent=2)`, picocolors for rich markup (color
|
|
10
10
|
* strips on a non-TTY sink so plain text is byte-exact), and output glyphs as
|
|
11
11
|
* `\u{...}` escapes emitted verbatim.
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync,
|
|
13
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { basename, extname, join, resolve } from 'node:path';
|
|
15
15
|
import pc from 'picocolors';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { CliExitError, jsonIndent2 } from './cli-common.js';
|
|
17
|
+
import { gatherAdoptionReport } from './core/adoption.js';
|
|
18
|
+
import { EXIT_ABSTAINED, errnoCode, gateOutcome, skippedSuffix, } from './core/gate-result.js';
|
|
19
|
+
import { promotionVerdict } from './core/promotion-verdict.js';
|
|
20
|
+
import { scanVacuity } from './core/vacuity-scanner.js';
|
|
18
21
|
import { ckInitCmd } from './company-knowledge-cli.js';
|
|
19
22
|
import { extractFrameworkHint } from './core/classifier.js';
|
|
20
23
|
import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
|
|
21
24
|
import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
|
|
22
|
-
import {
|
|
25
|
+
import { frameworkForPath } from './core/static-linter.js';
|
|
26
|
+
import { SCANNABLE_DESC, collectTestFiles, isDir } from './core/test-files.js';
|
|
23
27
|
import { RunSummary } from './core/ticket-updater.js';
|
|
24
28
|
import { renderBanner } from './ui/banner.js';
|
|
25
29
|
import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
|
|
@@ -39,68 +43,7 @@ function isFile(p) {
|
|
|
39
43
|
return false;
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
function
|
|
43
|
-
try {
|
|
44
|
-
return statSync(p).isDirectory();
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Directories never worth walking. A dependency's own test suite is not the
|
|
52
|
-
* consumer's to fix: before #566, `node_modules` accounted for 254 of 256
|
|
53
|
-
* findings in one downstream run, and the only `critical` sat inside vendored
|
|
54
|
-
* code. `pattern-matcher.ts` has carried this set since the Python port; this
|
|
55
|
-
* walk was the copy that never got it.
|
|
56
|
-
*/
|
|
57
|
-
const IGNORED_DIRS = new Set([
|
|
58
|
-
'node_modules',
|
|
59
|
-
'.git',
|
|
60
|
-
'__pycache__',
|
|
61
|
-
'.venv',
|
|
62
|
-
'venv',
|
|
63
|
-
'dist',
|
|
64
|
-
'build',
|
|
65
|
-
'.next',
|
|
66
|
-
'.nuxt',
|
|
67
|
-
]);
|
|
68
|
-
function walkFiles(dir) {
|
|
69
|
-
const out = [];
|
|
70
|
-
let entries;
|
|
71
|
-
try {
|
|
72
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return out;
|
|
76
|
-
}
|
|
77
|
-
for (const e of entries) {
|
|
78
|
-
const full = join(dir, e.name);
|
|
79
|
-
if (e.isDirectory()) {
|
|
80
|
-
if (!IGNORED_DIRS.has(e.name))
|
|
81
|
-
out.push(...walkFiles(full));
|
|
82
|
-
}
|
|
83
|
-
else if (e.isFile())
|
|
84
|
-
out.push(full);
|
|
85
|
-
}
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
|
|
90
|
-
* can actually read -- `.mjs` and `.cjs` included, which is the half of #566
|
|
91
|
-
* that made a directory of ESM tests collect zero files.
|
|
92
|
-
*/
|
|
93
|
-
const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
|
|
94
|
-
/** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
|
|
95
|
-
function collectTestFiles(dir) {
|
|
96
|
-
return walkFiles(dir)
|
|
97
|
-
.filter((p) => {
|
|
98
|
-
const b = basename(p);
|
|
99
|
-
return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
|
|
100
|
-
})
|
|
101
|
-
.sort();
|
|
102
|
-
}
|
|
103
|
-
export function recommendCmd(promptText, opts, deps) {
|
|
46
|
+
export function recommendFrameworkCmd(promptText, opts, deps) {
|
|
104
47
|
const classifier = deps.makeClassifier();
|
|
105
48
|
const recommender = deps.makeRecommender();
|
|
106
49
|
const classification = classifier.classify(promptText);
|
|
@@ -149,7 +92,7 @@ const TIER_STYLE = {
|
|
|
149
92
|
executable: pc.cyan,
|
|
150
93
|
catalog: pc.yellow,
|
|
151
94
|
};
|
|
152
|
-
export function
|
|
95
|
+
export function listFrameworksCmd(opts, deps) {
|
|
153
96
|
const summaries = deps.makeRegistry().summaries();
|
|
154
97
|
if (opts.json) {
|
|
155
98
|
deps.out(jsonIndent2({ frameworks: summaries }));
|
|
@@ -173,11 +116,11 @@ export function frameworksCmd(opts, deps) {
|
|
|
173
116
|
export function feedbackCmd(message, opts, deps) {
|
|
174
117
|
if (!VALID_CATEGORIES.includes(opts.category)) {
|
|
175
118
|
deps.out(`${pc.bold(pc.red(CROSS))} Unknown --category '${opts.category}'. Choose one of: ${VALID_CATEGORIES.join(', ')}.`);
|
|
176
|
-
throw new
|
|
119
|
+
throw new CliExitError(1);
|
|
177
120
|
}
|
|
178
121
|
if (!message || !message.trim()) {
|
|
179
122
|
deps.out(`${pc.bold(pc.red(CROSS))} A feedback message is required.\nUsage: ${pc.bold('canary feedback "<message>" [--category bug|ux|docs|idea]')}`);
|
|
180
|
-
throw new
|
|
123
|
+
throw new CliExitError(1);
|
|
181
124
|
}
|
|
182
125
|
const fb = buildFeedback(message.trim(), opts.category, resolveVersion(deps));
|
|
183
126
|
if (opts.json) {
|
|
@@ -290,7 +233,7 @@ function resolveMigrateOverlay(fromOverlay, overlay, deps) {
|
|
|
290
233
|
catch (e) {
|
|
291
234
|
if (e instanceof OverlayNotFound) {
|
|
292
235
|
deps.out(`\n${pc.bold(pc.red(CROSS))} ${e.message}`);
|
|
293
|
-
throw new
|
|
236
|
+
throw new CliExitError(1);
|
|
294
237
|
}
|
|
295
238
|
throw e;
|
|
296
239
|
}
|
|
@@ -307,14 +250,33 @@ function resolveMigrateOverlay(fromOverlay, overlay, deps) {
|
|
|
307
250
|
if (tracked.length > 1) {
|
|
308
251
|
const names = tracked.join(', ');
|
|
309
252
|
deps.out(`\n${pc.bold(pc.red(CROSS))} ${tracked.length} tracked overlays registered (${names}).\nChoose one with ${pc.bold('--from <name>')}.`);
|
|
310
|
-
throw new
|
|
253
|
+
throw new CliExitError(1);
|
|
311
254
|
}
|
|
312
255
|
return null;
|
|
313
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* `--adoption-report`: which adoption pieces are in place here (#459).
|
|
259
|
+
*
|
|
260
|
+
* Read-only, and unlike `--check` it runs WITHOUT an overlay: "no overlay is
|
|
261
|
+
* tracked" is itself one of the answers a half-adopted repo needs, so refusing
|
|
262
|
+
* to report until one exists would hide the most common gap.
|
|
263
|
+
*/
|
|
264
|
+
function migrateAdoption(migrator, root, overlayPath, json, deps) {
|
|
265
|
+
let report;
|
|
266
|
+
try {
|
|
267
|
+
report = gatherAdoptionReport(migrator, root, overlayPath, deps.home());
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
|
|
271
|
+
throw new CliExitError(1);
|
|
272
|
+
}
|
|
273
|
+
deps.out(json ? jsonIndent2(report.to_dict()) : report.to_markdown());
|
|
274
|
+
throw new CliExitError(report.exit_code());
|
|
275
|
+
}
|
|
314
276
|
function migrateCheck(migrator, root, overlayPath, json, deps) {
|
|
315
277
|
if (overlayPath === null) {
|
|
316
278
|
deps.out(`\n${pc.yellow('No overlay to check against.')} Track one with ${pc.bold('canary overlay add')} or pass ${pc.bold('--from <overlay>')}.`);
|
|
317
|
-
throw new
|
|
279
|
+
throw new CliExitError(0);
|
|
318
280
|
}
|
|
319
281
|
let report;
|
|
320
282
|
try {
|
|
@@ -322,7 +284,7 @@ function migrateCheck(migrator, root, overlayPath, json, deps) {
|
|
|
322
284
|
}
|
|
323
285
|
catch (e) {
|
|
324
286
|
deps.out(`\n${pc.bold(pc.red(CROSS))} ${e instanceof Error ? e.message : String(e)}`);
|
|
325
|
-
throw new
|
|
287
|
+
throw new CliExitError(1);
|
|
326
288
|
}
|
|
327
289
|
if (json) {
|
|
328
290
|
deps.out(jsonIndent2(report.to_dict()));
|
|
@@ -330,23 +292,37 @@ function migrateCheck(migrator, root, overlayPath, json, deps) {
|
|
|
330
292
|
else {
|
|
331
293
|
deps.out(report.to_markdown());
|
|
332
294
|
}
|
|
333
|
-
throw new
|
|
295
|
+
throw new CliExitError(report.exit_code());
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Dispatch the report-only modes, which each exit via `CliExitError`.
|
|
299
|
+
*
|
|
300
|
+
* One gate for both so `migrateCmd` carries a single "does this run write?"
|
|
301
|
+
* branch: a second report mode added inline is how the writing path acquires an
|
|
302
|
+
* accidental fall-through.
|
|
303
|
+
*/
|
|
304
|
+
function migrateReportOnly(opts, migrator, root, overlayPath, deps) {
|
|
305
|
+
const json = opts.json ?? false;
|
|
306
|
+
if (opts.adoptionReport) {
|
|
307
|
+
migrateAdoption(migrator, root, overlayPath, json, deps);
|
|
308
|
+
}
|
|
309
|
+
if (opts.check) {
|
|
310
|
+
migrateCheck(migrator, root, overlayPath, json, deps);
|
|
311
|
+
}
|
|
334
312
|
}
|
|
335
313
|
export function migrateCmd(opts, deps) {
|
|
336
314
|
const root = resolve(opts.path);
|
|
337
315
|
const overlayPath = resolveMigrateOverlay(opts.from, opts.overlay, deps);
|
|
338
316
|
const migrator = deps.makeMigrator();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
return; // unreachable -- migrateCheck always throws CliExit
|
|
342
|
-
}
|
|
317
|
+
// Both report-only modes exit here; neither ever reaches the writing path.
|
|
318
|
+
migrateReportOnly(opts, migrator, root, overlayPath, deps);
|
|
343
319
|
let ctx;
|
|
344
320
|
try {
|
|
345
321
|
ctx = migrator.detect(root);
|
|
346
322
|
}
|
|
347
323
|
catch (e) {
|
|
348
324
|
deps.out(`\n${pc.bold(pc.red('Detection error:'))} ${e instanceof Error ? e.message : String(e)}`);
|
|
349
|
-
throw new
|
|
325
|
+
throw new CliExitError(1);
|
|
350
326
|
}
|
|
351
327
|
if (!ctx.is_harness_project) {
|
|
352
328
|
if (ctx.not_test_project_reason) {
|
|
@@ -355,7 +331,7 @@ export function migrateCmd(opts, deps) {
|
|
|
355
331
|
else {
|
|
356
332
|
deps.out(`\n${pc.bold(pc.red(CROSS))} No harness project detected at ${pc.bold(root)}.\nExpected ${pc.dim('harness.config.json')} and ${pc.dim('.harness/')} directory.`);
|
|
357
333
|
}
|
|
358
|
-
throw new
|
|
334
|
+
throw new CliExitError(1);
|
|
359
335
|
}
|
|
360
336
|
const dryRun = !opts.apply;
|
|
361
337
|
const modeLabel = dryRun ? pc.dim('(dry run)') : pc.green('(apply)');
|
|
@@ -373,7 +349,7 @@ export function migrateCmd(opts, deps) {
|
|
|
373
349
|
}
|
|
374
350
|
catch (e) {
|
|
375
351
|
deps.out(`\n${pc.bold(pc.red('Error:'))} ${e instanceof Error ? e.message : String(e)}`);
|
|
376
|
-
throw new
|
|
352
|
+
throw new CliExitError(1);
|
|
377
353
|
}
|
|
378
354
|
if (opts.json) {
|
|
379
355
|
deps.out(jsonIndent2({
|
|
@@ -417,11 +393,15 @@ function findingPayload(f) {
|
|
|
417
393
|
suggestion: f.suggestion,
|
|
418
394
|
};
|
|
419
395
|
}
|
|
420
|
-
/** Human-readable list of what the collectors look for, for remedy text. */
|
|
421
|
-
const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
|
|
422
396
|
/** Emit the abstention notice in the caller's output mode, then exit 3. */
|
|
423
|
-
function abstain(remedy, deps, json) {
|
|
424
|
-
const
|
|
397
|
+
function abstain(remedy, deps, json, skipped = []) {
|
|
398
|
+
const result = { checked: 0, findings: [] };
|
|
399
|
+
// Only attach a non-empty list: `skippedSuffix` renders nothing for an empty
|
|
400
|
+
// one, but leaving the key set would put `"skipped": []` in the shape other
|
|
401
|
+
// producers use to mean "nothing was dropped".
|
|
402
|
+
if (skipped.length > 0)
|
|
403
|
+
result.skipped = skipped;
|
|
404
|
+
const outcome = gateOutcome(result, 'gate');
|
|
425
405
|
if (json) {
|
|
426
406
|
deps.out(jsonIndent2([]));
|
|
427
407
|
deps.err(`${outcome.summaryLine} ${remedy}`);
|
|
@@ -430,7 +410,7 @@ function abstain(remedy, deps, json) {
|
|
|
430
410
|
deps.out(pc.bold(pc.yellow(outcome.summaryLine)));
|
|
431
411
|
deps.out(` ${remedy}`);
|
|
432
412
|
}
|
|
433
|
-
throw new
|
|
413
|
+
throw new CliExitError(outcome.exitCode);
|
|
434
414
|
}
|
|
435
415
|
/**
|
|
436
416
|
* The denominator guard shared by the file-scanning gates (#508 Wave 4a).
|
|
@@ -468,6 +448,59 @@ function abstainOnUnlintableFile(path, deps, json) {
|
|
|
468
448
|
abstain(`Cannot lint ${ext} — no ruleset parses it, so a clean result would be ` +
|
|
469
449
|
`meaningless (looked for ${SCANNABLE_DESC}).`, deps, json);
|
|
470
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Run every collected file through `scan`, turning an unreadable one into a
|
|
453
|
+
* skip instead of an escaped exception (#704).
|
|
454
|
+
*
|
|
455
|
+
* The third zero these commands can hit. `abstainOnZeroFiles` guards the
|
|
456
|
+
* COLLECTOR and `abstainOnUnlintableFile` guards the RULESET; neither sees a
|
|
457
|
+
* path that collects fine and then cannot be opened, so `readFileSync` threw
|
|
458
|
+
* straight out of the handler and the CLI printed a raw Node stack. Not false
|
|
459
|
+
* green -- it exited 1 -- but exit 1 means "I found findings", and a run that
|
|
460
|
+
* opened nothing found nothing because it looked at nothing. Same shape
|
|
461
|
+
* `scanVacuity` and `promotionVerdict` already use: catch an errno-coded error,
|
|
462
|
+
* report a zero denominator plus a skip entry naming the cause.
|
|
463
|
+
*
|
|
464
|
+
* A fault with no errno code still throws. Swallowing an unknown one is how a
|
|
465
|
+
* scanner learns to go quiet.
|
|
466
|
+
*/
|
|
467
|
+
function sweepFiles(files, scan) {
|
|
468
|
+
const sweep = { findings: [], checked: 0, skipped: [] };
|
|
469
|
+
for (const f of files) {
|
|
470
|
+
try {
|
|
471
|
+
sweep.findings.push(...scan(f));
|
|
472
|
+
sweep.checked += 1;
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
const code = errnoCode(e);
|
|
476
|
+
if (code === null)
|
|
477
|
+
throw e;
|
|
478
|
+
sweep.skipped.push({ name: f, reason: `could not be read (${code})` });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return sweep;
|
|
482
|
+
}
|
|
483
|
+
/** Every collected file was unreadable: nothing was inspected, so exit 3. */
|
|
484
|
+
function abstainOnUnreadable(sweep, path, deps, json) {
|
|
485
|
+
if (sweep.checked > 0)
|
|
486
|
+
return;
|
|
487
|
+
abstain(`Nothing under ${path} could be opened, so this is an absent result and ` +
|
|
488
|
+
`not a clean one. Check the path exists and is readable.`, deps, json, sweep.skipped);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* D7 for the partial read: a file dropped from a run that still had a
|
|
492
|
+
* denominator stays visible, or "clean" silently covers less than it claims.
|
|
493
|
+
* In `--json` the notice rides stderr so stdout stays a parseable array.
|
|
494
|
+
*/
|
|
495
|
+
function reportSkips(sweep, deps, json) {
|
|
496
|
+
if (sweep.skipped.length === 0)
|
|
497
|
+
return;
|
|
498
|
+
const line = `${WARN} Not every collected file was read${skippedSuffix(sweep.skipped)}`;
|
|
499
|
+
if (json)
|
|
500
|
+
deps.err(line);
|
|
501
|
+
else
|
|
502
|
+
deps.out(pc.yellow(line));
|
|
503
|
+
}
|
|
471
504
|
export function reviewTestCmd(path, opts, deps) {
|
|
472
505
|
const json = opts.json === true;
|
|
473
506
|
const files = isDir(path) ? collectTestFiles(path) : [path];
|
|
@@ -475,16 +508,17 @@ export function reviewTestCmd(path, opts, deps) {
|
|
|
475
508
|
if (!isDir(path) && !opts.framework)
|
|
476
509
|
abstainOnUnlintableFile(path, deps, json);
|
|
477
510
|
const linter = deps.makeLinter();
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
511
|
+
const sweep = sweepFiles(files, (f) => linter.lint(f, opts.framework));
|
|
512
|
+
abstainOnUnreadable(sweep, path, deps, json);
|
|
513
|
+
reportSkips(sweep, deps, json);
|
|
514
|
+
const allFindings = sweep.findings;
|
|
481
515
|
// `--json` renders the machine payload and then falls through to the same
|
|
482
516
|
// exit-code decision as human mode. It used to `return` here, so a consumer
|
|
483
517
|
// gating on `$?` saw every finding-bearing run as clean (#566).
|
|
484
518
|
if (json) {
|
|
485
519
|
deps.out(jsonIndent2(allFindings.map(findingPayload)));
|
|
486
520
|
if (allFindings.some((f) => f.severity === 'critical')) {
|
|
487
|
-
throw new
|
|
521
|
+
throw new CliExitError(1);
|
|
488
522
|
}
|
|
489
523
|
return;
|
|
490
524
|
}
|
|
@@ -512,7 +546,7 @@ export function reviewTestCmd(path, opts, deps) {
|
|
|
512
546
|
parts.push(pc.dim(`${counts['info']} info`));
|
|
513
547
|
deps.out(`${pc.bold(`${allFindings.length} finding(s):`)} ${parts.join(', ')}`);
|
|
514
548
|
if (counts['critical'])
|
|
515
|
-
throw new
|
|
549
|
+
throw new CliExitError(1);
|
|
516
550
|
}
|
|
517
551
|
export function flakeCheckCmd(path, opts, deps) {
|
|
518
552
|
const json = opts.json === true;
|
|
@@ -521,14 +555,15 @@ export function flakeCheckCmd(path, opts, deps) {
|
|
|
521
555
|
if (!isDir(path))
|
|
522
556
|
abstainOnUnlintableFile(path, deps, json);
|
|
523
557
|
const linter = deps.makeLinter();
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
558
|
+
const sweep = sweepFiles(files, (f) => linter.flakeCheck(f));
|
|
559
|
+
abstainOnUnreadable(sweep, path, deps, json);
|
|
560
|
+
reportSkips(sweep, deps, json);
|
|
561
|
+
const allFindings = sweep.findings;
|
|
527
562
|
// Exit-code parity with human mode, same reason as `review-test` above.
|
|
528
563
|
if (json) {
|
|
529
564
|
deps.out(jsonIndent2(allFindings.map(findingPayload)));
|
|
530
565
|
if (allFindings.length > 0)
|
|
531
|
-
throw new
|
|
566
|
+
throw new CliExitError(1);
|
|
532
567
|
return;
|
|
533
568
|
}
|
|
534
569
|
if (allFindings.length === 0) {
|
|
@@ -542,12 +577,119 @@ export function flakeCheckCmd(path, opts, deps) {
|
|
|
542
577
|
deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
|
|
543
578
|
}
|
|
544
579
|
deps.out(pc.bold(`${allFindings.length} flakiness pattern(s) found.`));
|
|
545
|
-
throw new
|
|
580
|
+
throw new CliExitError(1);
|
|
581
|
+
}
|
|
582
|
+
// --- vacuity-check (canary-cassandra, #612) ----------------------------------
|
|
583
|
+
/**
|
|
584
|
+
* The CLI surface of the vacuity scanner.
|
|
585
|
+
*
|
|
586
|
+
* Registered as an **advisory** command, not a gate: this repo's established
|
|
587
|
+
* shape for a brand-new detector is advisory first, ratchet to strict only after
|
|
588
|
+
* triage (the dogfooding jobs, #485). So findings exit 0.
|
|
589
|
+
*
|
|
590
|
+
* The abstention half is NOT advisory, and the asymmetry is the whole #508
|
|
591
|
+
* doctrine. "I found weak tests" is information a repo absorbs over time; "I
|
|
592
|
+
* verified nothing" is a broken instrument, and a vacuity detector that reported
|
|
593
|
+
* its own silence as success would be the exact false-green class it exists to
|
|
594
|
+
* find. Two distinct zeros are guarded: no file matched, and files matched but
|
|
595
|
+
* held no tests -- a scanner that only checked the first prints a clean tick on
|
|
596
|
+
* the second.
|
|
597
|
+
*/
|
|
598
|
+
export function vacuityCheckCmd(path, opts, deps) {
|
|
599
|
+
const json = opts.json === true;
|
|
600
|
+
const files = isDir(path) ? collectTestFiles(path) : [path];
|
|
601
|
+
const findings = [];
|
|
602
|
+
const skipped = [];
|
|
603
|
+
let checked = 0;
|
|
604
|
+
for (const f of files) {
|
|
605
|
+
const r = scanVacuity(f);
|
|
606
|
+
checked += r.checked;
|
|
607
|
+
findings.push(...r.findings);
|
|
608
|
+
if (r.skipped)
|
|
609
|
+
skipped.push(...r.skipped);
|
|
610
|
+
}
|
|
611
|
+
if (files.length === 0) {
|
|
612
|
+
skipped.push({
|
|
613
|
+
name: path,
|
|
614
|
+
reason: `no test file matched (looked for ${SCANNABLE_DESC})`,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const result = { checked, findings };
|
|
618
|
+
if (skipped.length > 0)
|
|
619
|
+
result.skipped = skipped;
|
|
620
|
+
const outcome = gateOutcome(result, 'advisory', { noun: 'test(s)' });
|
|
621
|
+
// `advisory` keeps findings at exit 0; the abstention still has to be loud, so
|
|
622
|
+
// the exit code for a zero denominator is taken from the gate contract.
|
|
623
|
+
const exitCode = outcome.abstained ? EXIT_ABSTAINED : 0;
|
|
624
|
+
if (json) {
|
|
625
|
+
deps.out(jsonIndent2({
|
|
626
|
+
checked,
|
|
627
|
+
abstained: outcome.abstained,
|
|
628
|
+
findings,
|
|
629
|
+
skipped,
|
|
630
|
+
}));
|
|
631
|
+
if (exitCode !== 0)
|
|
632
|
+
throw new CliExitError(exitCode);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
for (const f of findings) {
|
|
636
|
+
const color = f.severity === 'critical' ? pc.red : pc.yellow;
|
|
637
|
+
const tier = f.fidelity ? pc.dim(` [${f.fidelity}]`) : '';
|
|
638
|
+
deps.out(`${color(`[${f.severity.toUpperCase()}]`)} ${f.file}:${f.line} ${pc.dim(`(${f.rule})`)}${tier}`);
|
|
639
|
+
deps.out(` ${f.test}: ${f.message}`);
|
|
640
|
+
deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
|
|
641
|
+
}
|
|
642
|
+
deps.out(outcome.abstained
|
|
643
|
+
? pc.bold(pc.yellow(outcome.summaryLine))
|
|
644
|
+
: `${pc.bold(outcome.summaryLine)}`);
|
|
645
|
+
if (exitCode !== 0)
|
|
646
|
+
throw new CliExitError(exitCode);
|
|
647
|
+
}
|
|
648
|
+
// --- promote-check (#477) ----------------------------------------------------
|
|
649
|
+
/**
|
|
650
|
+
* The gate `canary-promote-test` Phase 1 consumes.
|
|
651
|
+
*
|
|
652
|
+
* Unlike `vacuity-check` this IS a gate, and deliberately so: it does not scan a
|
|
653
|
+
* repository, it decides whether ONE generated draft may enter the committed
|
|
654
|
+
* suite. A blocking finding there is not a backlog item to triage, it is a
|
|
655
|
+
* reason not to import the draft. Nothing existing turns red -- the command is
|
|
656
|
+
* new and only ever pointed at `tests/generated/`.
|
|
657
|
+
*/
|
|
658
|
+
export function promoteCheckCmd(path, opts, deps) {
|
|
659
|
+
const verdict = promotionVerdict(path);
|
|
660
|
+
if (opts.json === true) {
|
|
661
|
+
deps.out(jsonIndent2(verdict));
|
|
662
|
+
if (verdict.exitCode !== 0)
|
|
663
|
+
throw new CliExitError(verdict.exitCode);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
for (const axis of verdict.axes) {
|
|
667
|
+
for (const f of axis.findings) {
|
|
668
|
+
const blocking = axis.gating && verdict.blocked.includes(f.rule);
|
|
669
|
+
const label = blocking ? pc.red('[BLOCK]') : pc.yellow('[ADVISORY]');
|
|
670
|
+
const tier = f.fidelity ? pc.dim(` [${f.fidelity}]`) : '';
|
|
671
|
+
deps.out(`${label} ${verdict.file}:${f.line} ${pc.dim(`(${f.rule}/${axis.axis})`)}${tier}`);
|
|
672
|
+
deps.out(` ${f.message}`);
|
|
673
|
+
deps.out(` ${pc.dim(`${ARROW} ${f.suggestion}`)}\n`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// No glyph on `abstain`: `gateOutcome` already opens its summary line with the
|
|
677
|
+
// warning sign, and two in a row read as a rendering bug rather than emphasis.
|
|
678
|
+
const banner = {
|
|
679
|
+
promote: pc.green(`${CHECK_MARK} PROMOTE`),
|
|
680
|
+
block: pc.red(`${REDX} BLOCK`),
|
|
681
|
+
abstain: pc.yellow('ABSTAIN'),
|
|
682
|
+
};
|
|
683
|
+
deps.out(`${pc.bold(banner[verdict.decision])} ${EM_DASH} ${verdict.summaryLine}`);
|
|
684
|
+
if (verdict.remedy)
|
|
685
|
+
deps.out(` ${verdict.remedy}`);
|
|
686
|
+
if (verdict.exitCode !== 0)
|
|
687
|
+
throw new CliExitError(verdict.exitCode);
|
|
546
688
|
}
|
|
547
689
|
export function healTestCmd(path, opts, deps) {
|
|
548
690
|
if (!isFile(path)) {
|
|
549
691
|
deps.out(pc.red(`Error: ${path} is not a file.`));
|
|
550
|
-
throw new
|
|
692
|
+
throw new CliExitError(1);
|
|
551
693
|
}
|
|
552
694
|
const result = deps.makeHealer().heal(path);
|
|
553
695
|
if (opts.json) {
|
|
@@ -631,17 +773,21 @@ export function upgradeCmd(opts, deps) {
|
|
|
631
773
|
}
|
|
632
774
|
else {
|
|
633
775
|
deps.out(`${pc.red('Upgrade failed.')}\n${pip.stderr.trim()}`);
|
|
634
|
-
throw new
|
|
776
|
+
throw new CliExitError(1);
|
|
635
777
|
}
|
|
636
778
|
}
|
|
637
|
-
// --- overlay / doctor (npm-shim pointers)
|
|
638
|
-
export function
|
|
779
|
+
// --- overlay / doctor / uninstall (npm-shim pointers) ------------------------
|
|
780
|
+
export function overlayCmd(deps) {
|
|
639
781
|
deps.out(`${pc.yellow('`canary overlay` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the overlay commands.`);
|
|
640
|
-
throw new
|
|
782
|
+
throw new CliExitError(1);
|
|
641
783
|
}
|
|
642
|
-
export function
|
|
784
|
+
export function doctorCmd(deps) {
|
|
643
785
|
deps.out(`${pc.yellow('`canary doctor` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the doctor command.`);
|
|
644
|
-
throw new
|
|
786
|
+
throw new CliExitError(1);
|
|
787
|
+
}
|
|
788
|
+
export function uninstallCmd(deps) {
|
|
789
|
+
deps.out(`${pc.yellow('`canary uninstall` is provided by the npm install of Canary.')}\nInstall it with: ${pc.bold('npm install -g canary-test-cli')}\nThe pipx/Python entry point does not include the uninstall command.`);
|
|
790
|
+
throw new CliExitError(1);
|
|
645
791
|
}
|
|
646
792
|
export async function ticketUpdateCmd(opts, deps) {
|
|
647
793
|
let reportData = {};
|
|
@@ -651,7 +797,7 @@ export async function ticketUpdateCmd(opts, deps) {
|
|
|
651
797
|
}
|
|
652
798
|
catch (exc) {
|
|
653
799
|
deps.out(pc.red(`Could not read result file '${opts.result}': ${exc instanceof Error ? exc.message : String(exc)}`));
|
|
654
|
-
throw new
|
|
800
|
+
throw new CliExitError(1);
|
|
655
801
|
}
|
|
656
802
|
}
|
|
657
803
|
const testFile = opts.testFile;
|
|
@@ -702,7 +848,7 @@ export async function ticketUpdateCmd(opts, deps) {
|
|
|
702
848
|
}
|
|
703
849
|
}
|
|
704
850
|
if (updateResult.transition.reason.startsWith(WARN)) {
|
|
705
|
-
throw new
|
|
851
|
+
throw new CliExitError(1);
|
|
706
852
|
}
|
|
707
853
|
}
|
|
708
854
|
/** Python string `<`/`>` comparison (stable code-unit order). */
|
|
@@ -5,21 +5,26 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The three concerns every command tree needs:
|
|
7
7
|
*
|
|
8
|
-
* - {@link
|
|
9
|
-
* `typer.Exit(code)`). Re-exported from `guardian/cli.ts` so the
|
|
10
|
-
* command tree (main program, its sub-apps, AND the already-ported
|
|
11
|
-
* sub-app it mounts) throws ONE class. `bin/canary.js`
|
|
12
|
-
* `
|
|
8
|
+
* - {@link CliExitError} -- the business-exit signal thrown from a handler
|
|
9
|
+
* (Python `typer.Exit(code)`). Re-exported from `guardian/cli.ts` so the
|
|
10
|
+
* ENTIRE command tree (main program, its sub-apps, AND the already-ported
|
|
11
|
+
* guardian sub-app it mounts) throws ONE class. `bin/canary.js` catches one
|
|
12
|
+
* `CliExitError` regardless of which sub-app raised it.
|
|
13
13
|
* - {@link normalizeUsageExit} -- the `.exitOverride()` callback that maps
|
|
14
14
|
* commander's usage-error exit code (1) to typer/click's 2, while leaving an
|
|
15
15
|
* explicit `--help`/`--version` at 0. Applied to the program AND every
|
|
16
16
|
* subcommand.
|
|
17
|
-
* - {@link
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* - {@link jsonIndent2} -- Python `json.dumps(x, indent=2)` fidelity: the
|
|
18
|
+
* separators match `JSON.stringify(x, null, 2)` byte-for-byte, and the
|
|
19
|
+
* `ensure_ascii=True` default is restored by `ensureAscii`.
|
|
20
|
+
*
|
|
21
|
+
* `ensureAscii` itself used to be declared and exported here, and was imported
|
|
22
|
+
* by nobody while eight modules carried private copies (#710). It now lives in
|
|
23
|
+
* `util/ensure-ascii.ts` -- the one layer `core`, `guardian`, and the entry
|
|
24
|
+
* modules are all permitted to depend on, which `cli` is not.
|
|
21
25
|
*/
|
|
22
|
-
|
|
26
|
+
import { ensureAscii } from './util/ensure-ascii.js';
|
|
27
|
+
export { CliExitError } from './guardian/cli.js';
|
|
23
28
|
/**
|
|
24
29
|
* Map commander's usage-error exit code to typer/click's `2`. Commander defaults
|
|
25
30
|
* unknown/missing-option, bad-command and no-args-help errors to exit 1; typer
|
|
@@ -33,20 +38,6 @@ export function normalizeUsageExit(err) {
|
|
|
33
38
|
}
|
|
34
39
|
throw err;
|
|
35
40
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Escape every non-ASCII UTF-16 code UNIT to `\uXXXX`, matching Python
|
|
38
|
-
* `json.dumps(ensure_ascii=True)`. Iterating by unit (not code point) means an
|
|
39
|
-
* astral char's surrogate pair emits `\udXXX\udXXX`, exactly like CPython; a
|
|
40
|
-
* code-point regex would stop at U+FFFF and leave astral chars raw.
|
|
41
|
-
*/
|
|
42
|
-
export function ensureAscii(json) {
|
|
43
|
-
let out = '';
|
|
44
|
-
for (let i = 0; i < json.length; i++) {
|
|
45
|
-
const c = json.charCodeAt(i);
|
|
46
|
-
out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
41
|
/**
|
|
51
42
|
* `json.dumps(x, indent=2)` fidelity: `JSON.stringify(x, null, 2)` reproduces
|
|
52
43
|
* Python's 2-space indent and `(',', ': ')` separators byte-for-byte (including
|