canary-test-cli 6.2.0 → 6.4.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/bin/canary-mcp.js +52 -0
- package/dist/engine/cli-commands.js +3 -1
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +125 -40
- package/dist/engine/core/feedback.js +32 -18
- package/dist/engine/core/gate-result.js +73 -0
- package/dist/engine/core/migrator.js +400 -30
- package/dist/engine/core/skill-registry.js +95 -30
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +70 -2
- package/dist/overlay-lint.d.ts +6 -1
- package/dist/overlay-lint.js +53 -68
- package/dist/skill-frontmatter.d.ts +24 -0
- package/dist/skill-frontmatter.js +89 -0
- package/package.json +4 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Console-script entry for `canary-mcp` (#507): starts the Canary MCP server
|
|
5
|
+
// over stdio from the bundled TypeScript engine (dist/engine/mcp-server.js,
|
|
6
|
+
// staged by scripts/build-engine.mjs). The engine bundle is ESM while this
|
|
7
|
+
// package is CommonJS, so the server module is loaded via dynamic import().
|
|
8
|
+
// stdout carries the JSON-RPC stream and must never be polluted -- every
|
|
9
|
+
// failure path writes to stderr only.
|
|
10
|
+
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const { pathToFileURL } = require('node:url');
|
|
14
|
+
|
|
15
|
+
/** Absolute path to the bundled engine's MCP server module. */
|
|
16
|
+
function getServerPath() {
|
|
17
|
+
return path.join(__dirname, '..', 'dist', 'engine', 'mcp-server.js');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start the server. Returns the exit code (0 when runStdio resolves, 1 when
|
|
22
|
+
* the bundle is missing). Dependencies are injectable for testing.
|
|
23
|
+
*/
|
|
24
|
+
async function main({
|
|
25
|
+
serverPath = getServerPath(),
|
|
26
|
+
existsSync = fs.existsSync,
|
|
27
|
+
stderr = process.stderr,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (!existsSync(serverPath)) {
|
|
30
|
+
stderr.write(
|
|
31
|
+
`canary MCP server not found at ${serverPath}.\n` +
|
|
32
|
+
`The package looks incomplete; try reinstalling: npm install -g canary-test-cli\n`,
|
|
33
|
+
);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const { runStdio } = await import(pathToFileURL(serverPath).href);
|
|
37
|
+
await runStdio();
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (require.main === module) {
|
|
42
|
+
main()
|
|
43
|
+
.then((code) => {
|
|
44
|
+
if (code !== 0) process.exit(code);
|
|
45
|
+
})
|
|
46
|
+
.catch((err) => {
|
|
47
|
+
console.error(err);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { getServerPath, main };
|
|
@@ -155,7 +155,7 @@ export function feedbackCmd(message, opts, deps) {
|
|
|
155
155
|
deps.out(`${pc.bold(pc.red(CROSS))} A feedback message is required.\nUsage: ${pc.bold('canary feedback "<message>" [--category bug|ux|docs|idea]')}`);
|
|
156
156
|
throw new CliExit(1);
|
|
157
157
|
}
|
|
158
|
-
const fb = buildFeedback(message.trim(), opts.category);
|
|
158
|
+
const fb = buildFeedback(message.trim(), opts.category, resolveVersion(deps));
|
|
159
159
|
if (opts.json) {
|
|
160
160
|
deps.out(jsonIndent2(fb));
|
|
161
161
|
return;
|
|
@@ -344,6 +344,7 @@ export function migrateCmd(opts, deps) {
|
|
|
344
344
|
dryRun,
|
|
345
345
|
framework: opts.framework || null,
|
|
346
346
|
overlayPath,
|
|
347
|
+
force: opts.force ?? false,
|
|
347
348
|
});
|
|
348
349
|
}
|
|
349
350
|
catch (e) {
|
|
@@ -367,6 +368,7 @@ export function migrateCmd(opts, deps) {
|
|
|
367
368
|
status: r.status,
|
|
368
369
|
note: r.note,
|
|
369
370
|
})),
|
|
371
|
+
installed_workflows: report.installed_workflows.map((r) => r.to_dict()),
|
|
370
372
|
}));
|
|
371
373
|
return;
|
|
372
374
|
}
|
package/dist/engine/cli.core.js
CHANGED
|
@@ -107,6 +107,7 @@ export function createCanaryCommand(depsInit = {}) {
|
|
|
107
107
|
.option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
|
|
108
108
|
.option('--apply', 'Write files. Without this flag the command is a dry run.')
|
|
109
109
|
.option('--check', 'Freshness gate: report drift without writing.')
|
|
110
|
+
.option('--force', 'Overwrite a .github/workflows/ file that differs from the overlay template. Without this flag a difference is only reported -- your CI is never rewritten behind your back.')
|
|
110
111
|
.option('--json', 'Emit the report as JSON.')
|
|
111
112
|
.action((opts) => {
|
|
112
113
|
migrateCmd(opts, deps);
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* 2. .canary/company.json -- project-local config
|
|
14
14
|
* 3. .canary/company.<env>.json -- environment override (CANARY_ENV or explicit)
|
|
15
15
|
*
|
|
16
|
-
* List fields are unioned across sources; scalar fields
|
|
17
|
-
*
|
|
18
|
-
* sets
|
|
16
|
+
* List fields ({@link _LIST_FIELDS}) are unioned across sources; scalar fields
|
|
17
|
+
* ({@link _SCALAR_FIELDS}) are replaced by the highest-priority source that
|
|
18
|
+
* sets a non-empty value. Those two arrays are the single place a new field
|
|
19
|
+
* opts into a merge rule, and both are checked for exhaustiveness at compile
|
|
20
|
+
* time.
|
|
19
21
|
*
|
|
20
22
|
* Python->TS nuances:
|
|
21
23
|
* - Python patches `Path.home()` in its tests to isolate the home tier. There
|
|
@@ -118,6 +120,12 @@ const _KNOWN_KEYS = new Set([
|
|
|
118
120
|
// warning that it is told anyone adopting an overlay that the single field
|
|
119
121
|
// driving their adoption does nothing.
|
|
120
122
|
'canary_shape',
|
|
123
|
+
// #459: repo-relative pointers a generated workflow interpolates (the
|
|
124
|
+
// coverage report the guardian reads; the controllers dir it scopes SUT
|
|
125
|
+
// analysis to). See `validateRepoRelativePath` for why they are validated
|
|
126
|
+
// rather than stored verbatim.
|
|
127
|
+
'coverage_report_path',
|
|
128
|
+
'sut_controllers_path',
|
|
121
129
|
]);
|
|
122
130
|
const _HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
123
131
|
const _BRAND_TEXT_MAX = 200;
|
|
@@ -240,6 +248,50 @@ function validateOtelEndpoint(raw, fieldName, warnings) {
|
|
|
240
248
|
}
|
|
241
249
|
return raw;
|
|
242
250
|
}
|
|
251
|
+
// A path that is absolute in any flavour git checkouts run under: POSIX
|
|
252
|
+
// (`/x`), UNC / Windows-separator (`\x`), or drive-qualified (`C:x`, `C:/x`).
|
|
253
|
+
// Drive-relative `C:x` is included deliberately -- it is not repo-relative.
|
|
254
|
+
const _ABSOLUTE_PATH_RE = /^(?:[/\\]|[A-Za-z]:)/;
|
|
255
|
+
// Newlines would break out of the scalar these values are interpolated into
|
|
256
|
+
// when a workflow template is generated, so they are refused outright rather
|
|
257
|
+
// than escaped -- no legitimate repo path contains one.
|
|
258
|
+
const _PATH_CONTROL_RE = /[\r\n\t\0]/;
|
|
259
|
+
/**
|
|
260
|
+
* A repo-relative path pointer (#459: `coverage_report_path`,
|
|
261
|
+
* `sut_controllers_path`).
|
|
262
|
+
*
|
|
263
|
+
* These are interpolated into generated GitHub Actions YAML, so validation is
|
|
264
|
+
* a safety boundary, not tidiness: an absolute path aims the generated CI at
|
|
265
|
+
* something outside the checkout, and a `..` segment escapes the repo. Both are
|
|
266
|
+
* dropped with a warning (the module's degrade-never-throw convention); a
|
|
267
|
+
* secret-like value raises so the whole layer is refused, exactly as every
|
|
268
|
+
* other non-notes field does.
|
|
269
|
+
*
|
|
270
|
+
* `..` is rejected as a SUBSTRING, not just as a path component. A component
|
|
271
|
+
* check would have to agree with the separator handling of whatever consumes
|
|
272
|
+
* the value later (shell, Actions expression, node `path`); refusing the two
|
|
273
|
+
* characters outright cannot disagree with anything. The cost is rejecting the
|
|
274
|
+
* vanishingly rare legitimate `report..xml`.
|
|
275
|
+
*/
|
|
276
|
+
function validateRepoRelativePath(raw, fieldName, warnings) {
|
|
277
|
+
if (typeof raw !== 'string') {
|
|
278
|
+
warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
|
|
279
|
+
return '';
|
|
280
|
+
}
|
|
281
|
+
const value = raw.trim();
|
|
282
|
+
if (!value)
|
|
283
|
+
return '';
|
|
284
|
+
if (looksLikeSecret(value))
|
|
285
|
+
throw new SecretDetected(fieldName, value);
|
|
286
|
+
if (_ABSOLUTE_PATH_RE.test(value) ||
|
|
287
|
+
value.includes('..') ||
|
|
288
|
+
_PATH_CONTROL_RE.test(value)) {
|
|
289
|
+
warnings.push(`${fieldName}: dropped invalid repo-relative path ${pyRepr(raw)} ` +
|
|
290
|
+
`${EMDASH} must stay inside the repo (no absolute path, no '..')`);
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
243
295
|
/** Accept #RGB / #RRGGBB (any case); drop anything else with a warning. */
|
|
244
296
|
function validateHexColor(raw, fieldName, warnings) {
|
|
245
297
|
if (typeof raw !== 'string' || !raw)
|
|
@@ -432,6 +484,14 @@ function parseLayer(data, source) {
|
|
|
432
484
|
if (Object.prototype.hasOwnProperty.call(data, 'otel_exporter_endpoint')) {
|
|
433
485
|
otel_exporter_endpoint = validateOtelEndpoint(data['otel_exporter_endpoint'], 'otel_exporter_endpoint', warns);
|
|
434
486
|
}
|
|
487
|
+
let coverage_report_path = '';
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(data, 'coverage_report_path')) {
|
|
489
|
+
coverage_report_path = validateRepoRelativePath(data['coverage_report_path'], 'coverage_report_path', warns);
|
|
490
|
+
}
|
|
491
|
+
let sut_controllers_path = '';
|
|
492
|
+
if (Object.prototype.hasOwnProperty.call(data, 'sut_controllers_path')) {
|
|
493
|
+
sut_controllers_path = validateRepoRelativePath(data['sut_controllers_path'], 'sut_controllers_path', warns);
|
|
494
|
+
}
|
|
435
495
|
let notes = '';
|
|
436
496
|
if (Object.prototype.hasOwnProperty.call(data, 'notes')) {
|
|
437
497
|
const rawNotes = data['notes'];
|
|
@@ -455,6 +515,8 @@ function parseLayer(data, source) {
|
|
|
455
515
|
dashboard_url,
|
|
456
516
|
dashboard_token_env,
|
|
457
517
|
otel_exporter_endpoint,
|
|
518
|
+
coverage_report_path,
|
|
519
|
+
sut_controllers_path,
|
|
458
520
|
notes,
|
|
459
521
|
brand,
|
|
460
522
|
warnings: warns,
|
|
@@ -509,51 +571,66 @@ function union(a, b) {
|
|
|
509
571
|
}
|
|
510
572
|
return out;
|
|
511
573
|
}
|
|
574
|
+
const _LIST_FIELDS = [
|
|
575
|
+
'confluence_spaces',
|
|
576
|
+
'jira_projects',
|
|
577
|
+
'internal_doc_urls',
|
|
578
|
+
'internal_domains',
|
|
579
|
+
'mcp_servers',
|
|
580
|
+
'claude_code_skills',
|
|
581
|
+
];
|
|
582
|
+
const _SCALAR_FIELDS = [
|
|
583
|
+
'dashboard_url',
|
|
584
|
+
'dashboard_token_env',
|
|
585
|
+
'otel_exporter_endpoint',
|
|
586
|
+
'coverage_report_path',
|
|
587
|
+
'sut_controllers_path',
|
|
588
|
+
'notes',
|
|
589
|
+
];
|
|
590
|
+
// Compile-time exhaustiveness: adding a field to `Layer`/`MergedFields` without
|
|
591
|
+
// adding it to the matching array above fails the build here (the assertion
|
|
592
|
+
// type collapses to `never`) rather than silently dropping the field at merge.
|
|
593
|
+
const _LIST_FIELDS_EXHAUSTIVE = true;
|
|
594
|
+
const _SCALAR_FIELDS_EXHAUSTIVE = true;
|
|
595
|
+
void _LIST_FIELDS_EXHAUSTIVE;
|
|
596
|
+
void _SCALAR_FIELDS_EXHAUSTIVE;
|
|
597
|
+
function mergeListFields(layers) {
|
|
598
|
+
const out = {};
|
|
599
|
+
for (const field of _LIST_FIELDS) {
|
|
600
|
+
let merged = [];
|
|
601
|
+
for (const layer of layers)
|
|
602
|
+
merged = union(merged, layer[field]);
|
|
603
|
+
out[field] = merged;
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
function mergeScalarFields(layers) {
|
|
608
|
+
const out = {};
|
|
609
|
+
for (const field of _SCALAR_FIELDS) {
|
|
610
|
+
let merged = '';
|
|
611
|
+
// Highest-priority non-empty wins. A layer whose value was DROPPED as
|
|
612
|
+
// invalid contributes '' and therefore leaves the lower layer's valid value
|
|
613
|
+
// standing (degrade, never blank out) -- load-bearing, and pinned by tests.
|
|
614
|
+
for (const layer of layers)
|
|
615
|
+
if (layer[field])
|
|
616
|
+
merged = layer[field];
|
|
617
|
+
out[field] = merged;
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
512
621
|
function mergeLayers(layers) {
|
|
513
|
-
|
|
514
|
-
let jira_projects = [];
|
|
515
|
-
let internal_doc_urls = [];
|
|
516
|
-
let internal_domains = [];
|
|
517
|
-
let mcp_servers = [];
|
|
518
|
-
let claude_code_skills = [];
|
|
519
|
-
let dashboard_url = '';
|
|
520
|
-
let dashboard_token_env = '';
|
|
521
|
-
let otel_exporter_endpoint = '';
|
|
522
|
-
let notes = '';
|
|
523
|
-
const warns = [];
|
|
622
|
+
const warnings = [];
|
|
524
623
|
const sources = [];
|
|
525
624
|
for (const layer of layers) {
|
|
526
|
-
|
|
527
|
-
jira_projects = union(jira_projects, layer.jira_projects);
|
|
528
|
-
internal_doc_urls = union(internal_doc_urls, layer.internal_doc_urls);
|
|
529
|
-
internal_domains = union(internal_domains, layer.internal_domains);
|
|
530
|
-
mcp_servers = union(mcp_servers, layer.mcp_servers);
|
|
531
|
-
claude_code_skills = union(claude_code_skills, layer.claude_code_skills);
|
|
532
|
-
if (layer.dashboard_url)
|
|
533
|
-
dashboard_url = layer.dashboard_url;
|
|
534
|
-
if (layer.dashboard_token_env)
|
|
535
|
-
dashboard_token_env = layer.dashboard_token_env;
|
|
536
|
-
if (layer.otel_exporter_endpoint)
|
|
537
|
-
otel_exporter_endpoint = layer.otel_exporter_endpoint;
|
|
538
|
-
if (layer.notes)
|
|
539
|
-
notes = layer.notes;
|
|
540
|
-
warns.push(...layer.warnings);
|
|
625
|
+
warnings.push(...layer.warnings);
|
|
541
626
|
if (layer.source)
|
|
542
627
|
sources.push(layer.source);
|
|
543
628
|
}
|
|
544
629
|
return {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
internal_doc_urls,
|
|
548
|
-
internal_domains,
|
|
549
|
-
mcp_servers,
|
|
550
|
-
claude_code_skills,
|
|
551
|
-
dashboard_url,
|
|
552
|
-
dashboard_token_env,
|
|
553
|
-
otel_exporter_endpoint,
|
|
554
|
-
notes,
|
|
630
|
+
...mergeListFields(layers),
|
|
631
|
+
...mergeScalarFields(layers),
|
|
555
632
|
brand: mergeBrand(layers),
|
|
556
|
-
warnings
|
|
633
|
+
warnings,
|
|
557
634
|
sources,
|
|
558
635
|
};
|
|
559
636
|
}
|
|
@@ -585,6 +662,10 @@ export class CompanyKnowledge {
|
|
|
585
662
|
dashboard_url;
|
|
586
663
|
dashboard_token_env;
|
|
587
664
|
otel_exporter_endpoint;
|
|
665
|
+
/** Repo-relative path to the coverage report a generated workflow reads. */
|
|
666
|
+
coverage_report_path;
|
|
667
|
+
/** Repo-relative path to the SUT controllers dir analysis is scoped to. */
|
|
668
|
+
sut_controllers_path;
|
|
588
669
|
notes;
|
|
589
670
|
brand;
|
|
590
671
|
warnings;
|
|
@@ -600,6 +681,8 @@ export class CompanyKnowledge {
|
|
|
600
681
|
this.dashboard_url = init.dashboard_url ?? '';
|
|
601
682
|
this.dashboard_token_env = init.dashboard_token_env ?? '';
|
|
602
683
|
this.otel_exporter_endpoint = init.otel_exporter_endpoint ?? '';
|
|
684
|
+
this.coverage_report_path = init.coverage_report_path ?? '';
|
|
685
|
+
this.sut_controllers_path = init.sut_controllers_path ?? '';
|
|
603
686
|
this.notes = init.notes ?? '';
|
|
604
687
|
this.brand = init.brand ?? new Brand();
|
|
605
688
|
this.warnings = init.warnings ?? [];
|
|
@@ -733,6 +816,8 @@ export class CompanyKnowledge {
|
|
|
733
816
|
dashboard_url: this.dashboard_url,
|
|
734
817
|
dashboard_token_env: this.dashboard_token_env,
|
|
735
818
|
otel_exporter_endpoint: this.otel_exporter_endpoint,
|
|
819
|
+
coverage_report_path: this.coverage_report_path,
|
|
820
|
+
sut_controllers_path: this.sut_controllers_path,
|
|
736
821
|
notes: this.notes,
|
|
737
822
|
brand: this.brand.toDict(),
|
|
738
823
|
sources: this.sources,
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* never reads environment variables or file contents.
|
|
13
13
|
*
|
|
14
14
|
* Python→TS nuances:
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* - The context keys are `{version, os, runtime, install}` (insertion order
|
|
16
|
+
* preserved). `runtime` carries `process.version`; the key was named
|
|
17
|
+
* `python` for golden-parity shape fidelity until #506 — in filed issues
|
|
18
|
+
* it misled triage ("user on python v22?") once the Python engine retired.
|
|
19
19
|
* - `urlencode(...)` (which uses `quote_plus`, space -> `+`) maps to
|
|
20
20
|
* `URLSearchParams`, which also form-encodes with space -> `+` and preserves
|
|
21
21
|
* insertion order. Exotic-character percent-encoding can differ byte-for-byte
|
|
@@ -26,12 +26,6 @@ import { release, type } from 'node:os';
|
|
|
26
26
|
/** The public issue tracker (from npm/package.json `repository`). */
|
|
27
27
|
export const TRACKER_URL = 'https://github.com/bop-clocktower/canary';
|
|
28
28
|
export const VALID_CATEGORIES = ['bug', 'ux', 'docs', 'idea'];
|
|
29
|
-
function canaryVersion() {
|
|
30
|
-
// Python reads `importlib.metadata.version("canary-test-ai")`, falling back to
|
|
31
|
-
// "unknown". The TS pilot has no equivalent package-metadata lookup wired in,
|
|
32
|
-
// so we return the same best-effort "unknown" sentinel.
|
|
33
|
-
return 'unknown';
|
|
34
|
-
}
|
|
35
29
|
/** Best-effort install-method label — never fails, never inspects secrets. */
|
|
36
30
|
function installMethod() {
|
|
37
31
|
const exe = (process.execPath || '').toLowerCase();
|
|
@@ -45,23 +39,43 @@ function installMethod() {
|
|
|
45
39
|
*
|
|
46
40
|
* Deliberately excludes environment variables and file contents — only the
|
|
47
41
|
* coarse runtime facts a maintainer needs to triage a CLI report.
|
|
42
|
+
*
|
|
43
|
+
* `version` comes from the caller (#506): this module is pure and cannot know
|
|
44
|
+
* which package it shipped in, but the CLI layer does (`deps.pkgVersion()`),
|
|
45
|
+
* and the version is the single most useful triage field.
|
|
48
46
|
*/
|
|
49
|
-
export function collectContext() {
|
|
47
|
+
export function collectContext(version = 'unknown') {
|
|
50
48
|
return {
|
|
51
|
-
version
|
|
49
|
+
version,
|
|
52
50
|
os: `${type()} ${release()}`.trim(),
|
|
53
|
-
|
|
51
|
+
runtime: process.version,
|
|
54
52
|
install: installMethod(),
|
|
55
53
|
};
|
|
56
54
|
}
|
|
55
|
+
// Horizontal ellipsis, kept as an escape so this source stays ASCII.
|
|
56
|
+
const ELLIPSIS = '\u{2026}';
|
|
57
|
+
/**
|
|
58
|
+
* Cap the title at 60 code points (`Array.from` slices by code point, not
|
|
59
|
+
* UTF-16 unit, so astral chars do not truncate early). When the cap bites,
|
|
60
|
+
* break on the last word boundary inside the budget (falling back to a hard
|
|
61
|
+
* cut for an unbreakable token) and append an ellipsis so the truncation is
|
|
62
|
+
* visible instead of ending mid-word (#506).
|
|
63
|
+
*/
|
|
64
|
+
function truncateTitle(message) {
|
|
65
|
+
const points = Array.from(message);
|
|
66
|
+
if (points.length <= 60)
|
|
67
|
+
return message;
|
|
68
|
+
const hard = points.slice(0, 60).join('');
|
|
69
|
+
const lastBreak = hard.search(/\s+\S*$/);
|
|
70
|
+
const cut = lastBreak > 0 ? hard.slice(0, lastBreak) : hard;
|
|
71
|
+
return `${cut}${ELLIPSIS}`;
|
|
72
|
+
}
|
|
57
73
|
/**
|
|
58
74
|
* A pre-filled GitHub 'new issue' URL: category in the title, message + context
|
|
59
75
|
* in the body, category as a label. All parts are URL-encoded.
|
|
60
76
|
*/
|
|
61
77
|
export function buildIssueUrl(category, message, context) {
|
|
62
|
-
|
|
63
|
-
// unit, so an astral char would truncate the title early. Match the oracle.
|
|
64
|
-
const title = `[${category}] ${Array.from(message).slice(0, 60).join('')}`.trim();
|
|
78
|
+
const title = `[${category}] ${truncateTitle(message)}`.trim();
|
|
65
79
|
const bodyLines = [
|
|
66
80
|
message,
|
|
67
81
|
'',
|
|
@@ -81,8 +95,8 @@ export function buildIssueUrl(category, message, context) {
|
|
|
81
95
|
return `${TRACKER_URL}/issues/new?${query.toString()}`;
|
|
82
96
|
}
|
|
83
97
|
/** Bundle a report: message, category, context, and the pre-filled URL. */
|
|
84
|
-
export function buildFeedback(message, category) {
|
|
85
|
-
const context = collectContext();
|
|
98
|
+
export function buildFeedback(message, category, version = 'unknown') {
|
|
99
|
+
const context = collectContext(version);
|
|
86
100
|
return {
|
|
87
101
|
message,
|
|
88
102
|
category,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared gate-abstention helper (issue #508, no-silent-abstention spec).
|
|
3
|
+
*
|
|
4
|
+
* Doctrine: a check that verified zero items has ABSTAINED, not passed.
|
|
5
|
+
* Every gate reports its denominator (`checked`); zero is a distinct loud
|
|
6
|
+
* outcome. "Skipped" renders in every summary line and never aggregates
|
|
7
|
+
* into "passed" (D7).
|
|
8
|
+
*
|
|
9
|
+
* `gateOutcome` is the only path to a summary line for swept commands, so
|
|
10
|
+
* the refusal to print bare success on a zero denominator is structural.
|
|
11
|
+
* Surfaces append their own remediation text (why the denominator
|
|
12
|
+
* collapsed, first fix step) after the summary line.
|
|
13
|
+
*
|
|
14
|
+
* Output glyphs are written as `\u{...}` escapes so this source stays
|
|
15
|
+
* ASCII while the emitted bytes match the rest of the CLI (warning sign
|
|
16
|
+
* U+26A0, em dash U+2014).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Reserved CLI-wide (D4): exit 3 always means "abstained -- verified zero
|
|
20
|
+
* items", distinct from 0 (clean), 1 (findings), 2 (surface-specific).
|
|
21
|
+
*/
|
|
22
|
+
export const EXIT_ABSTAINED = 3;
|
|
23
|
+
const WARN = '\u{26A0}'; // warning sign
|
|
24
|
+
const EMDASH = '\u{2014}'; // em dash
|
|
25
|
+
// C0 controls (incl. \n, ESC) and DEL: a skip name must never be able to
|
|
26
|
+
// forge output lines or smuggle ANSI sequences into the summary.
|
|
27
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
28
|
+
/** D7: skipped entries render in EVERY summary line. */
|
|
29
|
+
function skippedSuffix(skipped) {
|
|
30
|
+
if (!skipped || skipped.length === 0)
|
|
31
|
+
return '';
|
|
32
|
+
const names = skipped
|
|
33
|
+
.map((s) => s.name.replace(CONTROL_CHARS, ''))
|
|
34
|
+
.join(', ');
|
|
35
|
+
return ` (${skipped.length} skipped: ${names})`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The single summary-line/exit-code path for swept commands.
|
|
39
|
+
*
|
|
40
|
+
* Non-abstained exit codes are helper defaults (findings -> 1 for gates);
|
|
41
|
+
* surfaces with richer contracts (e.g. freshness 2 = local edits) apply
|
|
42
|
+
* their own mapping AFTER checking `abstained`.
|
|
43
|
+
*/
|
|
44
|
+
export function gateOutcome(result, kind, opts = {}) {
|
|
45
|
+
const noun = opts.noun ?? 'check(s)';
|
|
46
|
+
const suffix = skippedSuffix(result.skipped);
|
|
47
|
+
// Findings outrank abstention: a finding proves something was checked,
|
|
48
|
+
// so it must never be masked by a collapsed/invalid denominator.
|
|
49
|
+
if (result.findings.length > 0) {
|
|
50
|
+
return {
|
|
51
|
+
exitCode: kind === 'gate' ? 1 : 0,
|
|
52
|
+
abstained: false,
|
|
53
|
+
summaryLine: `${result.findings.length} finding(s) across ` +
|
|
54
|
+
`${result.checked} checked${suffix}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Negated comparison so 0, negatives, and NaN all abstain: an invalid
|
|
58
|
+
// denominator must never render as success.
|
|
59
|
+
if (!(result.checked > 0)) {
|
|
60
|
+
return {
|
|
61
|
+
exitCode: kind === 'gate' ? EXIT_ABSTAINED : 0,
|
|
62
|
+
abstained: true,
|
|
63
|
+
summaryLine: `${WARN} Abstained ${EMDASH} verified zero items; ` +
|
|
64
|
+
`this is not a pass.${suffix}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
exitCode: 0,
|
|
69
|
+
abstained: false,
|
|
70
|
+
summaryLine: `All ${result.checked} run ${noun} passed${suffix}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=gate-result.js.map
|