canary-test-cli 5.15.0 → 6.0.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/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-tier capability boundary for canary-pr-guardian (Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/guardian/agent_tier.py`.
|
|
5
|
+
*
|
|
6
|
+
* This is the **one** module where agent orchestration is allowed -- it sits
|
|
7
|
+
* OUTSIDE the Tier-0 deterministic engine (`pr-check.ts`/`coverage.ts`/
|
|
8
|
+
* `tier.ts`), which imports it never (SC-11). Under the chosen **Option A**
|
|
9
|
+
* invocation mechanism it **never calls an LLM directly**: it (i) turns
|
|
10
|
+
* deterministic Tier-0 gaps into structured authoring intents, (ii) parses and
|
|
11
|
+
* validates agent results into `Finding`/`GeneratedTest` records, and (iii)
|
|
12
|
+
* enforces the write-safety model (opt-in, fork, collision, loop-guard) and the
|
|
13
|
+
* block-once decision. It reaches an agent runtime only through an injected
|
|
14
|
+
* `AgentInvoker` **port**; the production default (`RecordingInvoker`) records
|
|
15
|
+
* an intent and calls nothing, while the `canary-pr-guardian` SKILL fulfils it
|
|
16
|
+
* in-session.
|
|
17
|
+
*
|
|
18
|
+
* Python->TS nuances:
|
|
19
|
+
* - `pathlib.Path` fields become plain POSIX path strings (`test_paths`,
|
|
20
|
+
* `repo_root`), matching how the rest of the ts port models paths.
|
|
21
|
+
* - `dataclasses.replace(...)` becomes constructing a fresh `GeneratedTest`
|
|
22
|
+
* with a spread override (the invoker test double does this).
|
|
23
|
+
* - `_target_test_path` reimplements the `pathlib` parent/stem/suffix/as_posix
|
|
24
|
+
* algebra directly so a bare filename yields no `./` prefix (Python drops
|
|
25
|
+
* the `.` parent on join).
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
import { Severity } from './impact-mapper.js';
|
|
29
|
+
import { Finding } from './pr-check.js';
|
|
30
|
+
/** Construct a {@link ReviewRequest} (a frozen dataclass in Python). */
|
|
31
|
+
export function reviewRequest(test_paths) {
|
|
32
|
+
return { test_paths };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* An authoring intent and its (eventual) result.
|
|
36
|
+
*
|
|
37
|
+
* v1 `status`: `'planned'` (the {@link RecordingInvoker} default -- the SKILL
|
|
38
|
+
* fulfils it in-session), `'authored'` (a fake/real invoker wrote it), or
|
|
39
|
+
* `'skipped'` (a safety guard blocked it, with `skip_reason`).
|
|
40
|
+
*/
|
|
41
|
+
export class GeneratedTest {
|
|
42
|
+
gap;
|
|
43
|
+
target_path;
|
|
44
|
+
requirement; // NL requirement -> /canary-write-test $ARGUMENTS
|
|
45
|
+
status; // planned | authored | skipped
|
|
46
|
+
written_path;
|
|
47
|
+
skip_reason;
|
|
48
|
+
constructor(init) {
|
|
49
|
+
this.gap = init.gap;
|
|
50
|
+
this.target_path = init.target_path;
|
|
51
|
+
this.requirement = init.requirement;
|
|
52
|
+
this.status = init.status ?? 'planned';
|
|
53
|
+
this.written_path = init.written_path ?? null;
|
|
54
|
+
this.skip_reason = init.skip_reason ?? null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Default production invoker: calls NOTHING.
|
|
59
|
+
*
|
|
60
|
+
* Returns an empty transcript and a still-`planned` intent so the SKILL fulfils
|
|
61
|
+
* the work in-session (Option A). This is what keeps the deterministic layer
|
|
62
|
+
* LLM-free.
|
|
63
|
+
*/
|
|
64
|
+
export class RecordingInvoker {
|
|
65
|
+
review(_request) {
|
|
66
|
+
void _request;
|
|
67
|
+
return ''; // nothing reviewed here; SKILL drives canary-test-reviewer
|
|
68
|
+
}
|
|
69
|
+
author(intent) {
|
|
70
|
+
return intent; // status stays 'planned'; SKILL drives canary-test-author
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// A canary-test-reviewer transcript line: `[severity] path:line <message>`.
|
|
74
|
+
// The message tail is optional and captured as evidence. Anchored ^...$ with no
|
|
75
|
+
// flags -- matched per-line (the transcript is split first), so `$` binds the
|
|
76
|
+
// end of each line and `.` never needs to span newlines.
|
|
77
|
+
const REVIEW_LINE_RE = /^\s*\[(?<severity>[^\]]+)\]\s+(?<path>\S+?):(?<line>\d+)(?:\s+(?<msg>.*))?$/;
|
|
78
|
+
// Case-insensitive lookup of a reviewer severity token -> the Tier-0 Severity.
|
|
79
|
+
const SEVERITY_BY_NAME = new Map(Object.values(Severity).map((s) => [s, s]));
|
|
80
|
+
/**
|
|
81
|
+
* Parse `canary-test-reviewer`'s `[severity] path:line` lines into `weak-test`
|
|
82
|
+
* Findings.
|
|
83
|
+
*
|
|
84
|
+
* A malformed line (no `[severity] path:line` shape) or an unknown severity
|
|
85
|
+
* token is skipped, never raised. An empty transcript (the {@link
|
|
86
|
+
* RecordingInvoker} default) yields `[]` -- the SKILL reports its review
|
|
87
|
+
* directly in-session.
|
|
88
|
+
*/
|
|
89
|
+
export function parseReviewFindings(transcript) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
for (const raw of transcript.split(/\r\n|\r|\n/)) {
|
|
92
|
+
const match = REVIEW_LINE_RE.exec(raw);
|
|
93
|
+
if (match === null || match.groups === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
// `severity` and `path` are required capture groups -- present on any match.
|
|
96
|
+
const severityToken = match.groups['severity'];
|
|
97
|
+
const severity = SEVERITY_BY_NAME.get(severityToken.trim().toLowerCase());
|
|
98
|
+
if (severity === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
const path = match.groups['path'];
|
|
101
|
+
findings.push(new Finding({
|
|
102
|
+
path,
|
|
103
|
+
unit: path,
|
|
104
|
+
kind: 'weak-test',
|
|
105
|
+
severity,
|
|
106
|
+
evidence: (match.groups['msg'] ?? '').trim(),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
return findings;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* v1 {@link AgentTier}: drives `canary-test-reviewer`/`-author` via an injected
|
|
113
|
+
* invoker. Calls NO LLM itself (Option A) -- the invoker is the port.
|
|
114
|
+
*/
|
|
115
|
+
export class InSessionAgentTier {
|
|
116
|
+
invoker;
|
|
117
|
+
constructor(init = {}) {
|
|
118
|
+
this.invoker = init.invoker ?? new RecordingInvoker();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Tier 1 (read-only): ask the reviewer to audit `affected_tests` and parse the
|
|
122
|
+
* transcript into `weak-test` Findings. Writes nothing.
|
|
123
|
+
*/
|
|
124
|
+
audit_test_quality(affected_tests) {
|
|
125
|
+
const transcript = this.invoker.review(reviewRequest([...affected_tests]));
|
|
126
|
+
return parseReviewFindings(transcript);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Tier 2: plan the safe authoring intents, then hand each `planned` intent to
|
|
130
|
+
* the injected invoker (the {@link RecordingInvoker} default leaves it
|
|
131
|
+
* `planned` -- Option A; a fake/real invoker returns `authored`). `skipped`
|
|
132
|
+
* records pass through untouched. Absent `ctx` the caller gets the fail-closed
|
|
133
|
+
* default (opt-in off, tier 0 -> everything skipped).
|
|
134
|
+
*/
|
|
135
|
+
author_tests(gaps, ctx = null) {
|
|
136
|
+
const plan = planAuthoring(gaps, ctx ?? new AuthoringContext(false, 0));
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const intent of plan) {
|
|
139
|
+
if (intent.status === 'planned') {
|
|
140
|
+
out.push(this.invoker.author(intent)); // -> authored (fake/real)
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
out.push(intent); // skipped, unchanged
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Reports the agent tier ceiling from an explicit opt-in signal.
|
|
151
|
+
*
|
|
152
|
+
* Reads env `CANARY_GUARDIAN_AGENT` (`0|1|2`), which the SKILL exports when it
|
|
153
|
+
* runs in-session. Unset or invalid -> `0` (so CI, where the env is unset, stays
|
|
154
|
+
* Tier-0). Returns an `int` only -- imports no LLM (SC-11), so it drops into
|
|
155
|
+
* `resolveTier` unchanged as the real `AgentCapabilityProbe`.
|
|
156
|
+
*/
|
|
157
|
+
export class InSessionAgentProbe {
|
|
158
|
+
env;
|
|
159
|
+
constructor(env = process.env) {
|
|
160
|
+
this.env = env;
|
|
161
|
+
}
|
|
162
|
+
availableTier() {
|
|
163
|
+
const raw = this.env['CANARY_GUARDIAN_AGENT'] ?? '0';
|
|
164
|
+
return raw === '0' || raw === '1' || raw === '2'
|
|
165
|
+
? Number.parseInt(raw, 10)
|
|
166
|
+
: 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// --- Tier-2 authoring safety model (D4 guards a-d) -- pure, writes nothing. ---
|
|
170
|
+
// Language-specific test-path templates (peer-layout mirror).
|
|
171
|
+
const TS_JS_SUFFIXES = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']);
|
|
172
|
+
/**
|
|
173
|
+
* Everything `planAuthoring` needs to apply the four safety guards.
|
|
174
|
+
*
|
|
175
|
+
* All fields are supplied by the caller (the `author-plan` CLI seam); this keeps
|
|
176
|
+
* the guards pure and deterministic. `repo_root` is a POSIX path string (the
|
|
177
|
+
* Python `pathlib.Path` field).
|
|
178
|
+
*/
|
|
179
|
+
export class AuthoringContext {
|
|
180
|
+
author_tests_optin; // config.precommit_author_tests (default False) -- (d)
|
|
181
|
+
effective_tier; // from resolveTier (2 == can author)
|
|
182
|
+
is_fork; // reuse Phase-2 fork/403 detection -- (b)
|
|
183
|
+
repo_root; // collision + sentinel base
|
|
184
|
+
authored_sentinel_present; // loop-guard -- (a)
|
|
185
|
+
constructor(author_tests_optin, effective_tier, init = {}) {
|
|
186
|
+
this.author_tests_optin = author_tests_optin;
|
|
187
|
+
this.effective_tier = effective_tier;
|
|
188
|
+
this.is_fork = init.is_fork ?? false;
|
|
189
|
+
this.repo_root = init.repo_root ?? '.';
|
|
190
|
+
this.authored_sentinel_present = init.authored_sentinel_present ?? false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** POSIX `parent`/`stem`/`suffix` split mirroring `pathlib.PurePosixPath`. */
|
|
194
|
+
function posixParts(path) {
|
|
195
|
+
const segments = path.split('/');
|
|
196
|
+
const base = segments.pop() ?? '';
|
|
197
|
+
const parent = segments.join('/');
|
|
198
|
+
const dot = base.lastIndexOf('.');
|
|
199
|
+
if (dot > 0) {
|
|
200
|
+
return { parent, stem: base.slice(0, dot), suffix: base.slice(dot) };
|
|
201
|
+
}
|
|
202
|
+
return { parent, stem: base, suffix: '' };
|
|
203
|
+
}
|
|
204
|
+
/** Join a POSIX parent and child the way `pathlib` `/` + `as_posix()` does. */
|
|
205
|
+
function joinPosix(parent, child) {
|
|
206
|
+
return parent === '' || parent === '.' ? child : `${parent}/${child}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Deterministic target test path for a gap, mirroring peer test layout.
|
|
210
|
+
*
|
|
211
|
+
* - `.py` source -> `{parent}/test_{stem}.py` (pytest convention);
|
|
212
|
+
* - TS/JS source -> `{parent}/{stem}.test{suffix}` (jest/vitest convention);
|
|
213
|
+
* - anything else -> `{parent}/{stem}.test{suffix}` as a safe default.
|
|
214
|
+
*
|
|
215
|
+
* Returns a repo-relative POSIX path string.
|
|
216
|
+
*/
|
|
217
|
+
export function targetTestPath(gap) {
|
|
218
|
+
const { parent, stem, suffix } = posixParts(gap.path);
|
|
219
|
+
let targetName;
|
|
220
|
+
if (suffix === '.py') {
|
|
221
|
+
targetName = `test_${stem}.py`;
|
|
222
|
+
}
|
|
223
|
+
else if (TS_JS_SUFFIXES.has(suffix)) {
|
|
224
|
+
targetName = `${stem}.test${suffix}`;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
targetName = `${stem}.test${suffix || ''}`;
|
|
228
|
+
}
|
|
229
|
+
return joinPosix(parent, targetName);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* NL requirement string the `/canary-write-test` command consumes as
|
|
233
|
+
* `$ARGUMENTS` -- names the unit and the untested change.
|
|
234
|
+
*/
|
|
235
|
+
function requirementFor(gap) {
|
|
236
|
+
return (`Write tests for \`${gap.unit}\` in \`${gap.path}\` covering the newly ` +
|
|
237
|
+
`added, currently-untested code (guardian finding: ${gap.kind}).`);
|
|
238
|
+
}
|
|
239
|
+
// The skip-reason strings carry an em-dash (U+2014) as load-bearing output data
|
|
240
|
+
// (surfaced to the SKILL); written as an escape to honor the ASCII-source rule.
|
|
241
|
+
const EM_DASH = '\u{2014}';
|
|
242
|
+
/**
|
|
243
|
+
* Return the first failing guard's reason, or `null` if all guards pass.
|
|
244
|
+
*
|
|
245
|
+
* Guard order (fail-closed, cheapest/broadest first): (d) opt-in, (d) tier,
|
|
246
|
+
* (b) fork, (a) loop-guard, (c) collision.
|
|
247
|
+
*/
|
|
248
|
+
function authoringSkipReason(gap, ctx) {
|
|
249
|
+
if (!ctx.author_tests_optin) {
|
|
250
|
+
return `opt-in: preCommit.authorTests is not enabled ${EM_DASH} authoring is off by default`;
|
|
251
|
+
}
|
|
252
|
+
if (ctx.effective_tier < 2) {
|
|
253
|
+
return `tier: effective tier ${ctx.effective_tier} < 2 ${EM_DASH} cannot author`;
|
|
254
|
+
}
|
|
255
|
+
if (ctx.is_fork) {
|
|
256
|
+
return `fork: read-only ${EM_DASH} guardian never writes on a fork PR`;
|
|
257
|
+
}
|
|
258
|
+
if (ctx.authored_sentinel_present) {
|
|
259
|
+
return `loop-guard: guardian tests already authored this run ${EM_DASH} not re-authoring`;
|
|
260
|
+
}
|
|
261
|
+
const target = joinPosix(ctx.repo_root, targetTestPath(gap));
|
|
262
|
+
if (existsSync(target)) {
|
|
263
|
+
return `collision: ${targetTestPath(gap)} exists ${EM_DASH} another PR/session may own it`;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Apply the four D4 guards and emit one {@link GeneratedTest} per gap.
|
|
269
|
+
*
|
|
270
|
+
* A gap that clears every guard becomes a `planned` intent (non-empty
|
|
271
|
+
* `requirement` + `target_path`); any guard failure becomes a `skipped` record
|
|
272
|
+
* carrying the reason. Pure -- reads disk only to detect collisions (guard c)
|
|
273
|
+
* and writes **nothing**.
|
|
274
|
+
*/
|
|
275
|
+
export function planAuthoring(gaps, ctx) {
|
|
276
|
+
const out = [];
|
|
277
|
+
for (const gap of gaps) {
|
|
278
|
+
const target = targetTestPath(gap);
|
|
279
|
+
const requirement = requirementFor(gap);
|
|
280
|
+
const reason = authoringSkipReason(gap, ctx);
|
|
281
|
+
if (reason === null) {
|
|
282
|
+
out.push(new GeneratedTest({
|
|
283
|
+
gap,
|
|
284
|
+
target_path: target,
|
|
285
|
+
requirement,
|
|
286
|
+
status: 'planned',
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
out.push(new GeneratedTest({
|
|
291
|
+
gap,
|
|
292
|
+
target_path: target,
|
|
293
|
+
requirement,
|
|
294
|
+
status: 'skipped',
|
|
295
|
+
skip_reason: reason,
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
/** The pre-commit block-once decision computed from an authoring run. */
|
|
302
|
+
export class BlockDecision {
|
|
303
|
+
block;
|
|
304
|
+
message;
|
|
305
|
+
authored_count;
|
|
306
|
+
constructor(block, message, authored_count) {
|
|
307
|
+
this.block = block;
|
|
308
|
+
this.message = message;
|
|
309
|
+
this.authored_count = authored_count;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// The block message carries a no-entry sign (U+26D4) and an em-dash (U+2014) as
|
|
313
|
+
// load-bearing output data (shown to the committer); written as escapes.
|
|
314
|
+
const NO_ENTRY_SIGN = '\u{26D4}';
|
|
315
|
+
/**
|
|
316
|
+
* Block the commit ONCE iff >=1 ACTIONABLE (non-`skipped`) intent this run.
|
|
317
|
+
*
|
|
318
|
+
* Under Option A the production {@link RecordingInvoker} leaves every intent
|
|
319
|
+
* `planned` -- the SKILL then authors each non-`skipped` intent in-session -- so
|
|
320
|
+
* blocking only on `authored` would never gate the real path (the bug FIX 1
|
|
321
|
+
* fixes). An intent is actionable when its `status != "skipped"` (`planned` OR
|
|
322
|
+
* `authored`); an all-`skipped` run changed nothing, so it never blocks.
|
|
323
|
+
* `authored_count` counts those actionable intents (the field name the
|
|
324
|
+
* CLI/JSON/SKILL read). The message instructs "review ... then re-commit" (D4);
|
|
325
|
+
* the actual sentinel write + `git add` happen in the hook/SKILL -- this is pure
|
|
326
|
+
* decision logic.
|
|
327
|
+
*/
|
|
328
|
+
export function decideBlock(results) {
|
|
329
|
+
const actionable = results.filter((r) => r.status !== 'skipped');
|
|
330
|
+
const count = actionable.length;
|
|
331
|
+
if (count === 0) {
|
|
332
|
+
return new BlockDecision(false, '', 0);
|
|
333
|
+
}
|
|
334
|
+
const message = `${NO_ENTRY_SIGN} canary-guardian: ${count} test(s) authored & staged ` +
|
|
335
|
+
`${EM_DASH} review the generated code, then re-commit.`;
|
|
336
|
+
return new BlockDecision(true, message, count);
|
|
337
|
+
}
|
|
338
|
+
//# sourceMappingURL=agent-tier.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic emit for the harness reverse-handoff (#899) producer contract.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/guardian/analysis_emit.py`.
|
|
5
|
+
*
|
|
6
|
+
* Phase 5 turns the guardian's Tier-0 result into a **structured canary
|
|
7
|
+
* analysis** that a future harness gate surface can consume in-flow. This module
|
|
8
|
+
* owns the producer half:
|
|
9
|
+
*
|
|
10
|
+
* - {@link analysisFilename} -- the `canary-pr-guardian-<ref>.json` naming
|
|
11
|
+
* convention. The `canary-pr-guardian-` prefix is load-bearing: harness's own
|
|
12
|
+
* `AnalysisArchive` stores records as `<issueId>.json` and reads *every*
|
|
13
|
+
* `*.json` in `.harness/analyses/`, so the prefix + sanitized ref namespaces
|
|
14
|
+
* canary's records -- they never clobber a harness record and always pass
|
|
15
|
+
* `AnalysisArchive.safePath` (no traversal).
|
|
16
|
+
* - {@link buildAnalysisRecord} -- the v1.0 envelope wrapping the verbatim
|
|
17
|
+
* `render(fmt="json")` findings array.
|
|
18
|
+
*
|
|
19
|
+
* SC-11 boundary: this module is deterministic filesystem/JSON only. It imports
|
|
20
|
+
* no `AgentTier`/LLM-SDK module (only intra-guardian, agent-free helpers).
|
|
21
|
+
*
|
|
22
|
+
* Python->TS nuances:
|
|
23
|
+
* - `json.dumps(record, indent=2)` becomes `ensureAscii(JSON.stringify(record,
|
|
24
|
+
* null, 2))`. `JSON.stringify` with indent 2 matches Python's `(', ', ': ')`
|
|
25
|
+
* separators byte-for-byte; `ensureAscii` restores the `ensure_ascii=True`
|
|
26
|
+
* default so a non-ASCII evidence char (em-dash) escapes identically.
|
|
27
|
+
* - insertion-ordered object literals reproduce Python dict key order, and a
|
|
28
|
+
* first-seen-ordered plain object reproduces `Counter` -> `dict` order for
|
|
29
|
+
* `byFidelity`.
|
|
30
|
+
* - `tempfile.mkstemp` + `os.replace` becomes a same-dir random temp +
|
|
31
|
+
* `renameSync`, both behind {@link emitSeams} so tests can spy them the way
|
|
32
|
+
* the Python tests monkeypatch `emit_mod.os.replace` / `build_analysis_record`.
|
|
33
|
+
*/
|
|
34
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
35
|
+
import { mkdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
37
|
+
import { render } from './pr-check.js';
|
|
38
|
+
export const SCHEMA_VERSION = '1.0';
|
|
39
|
+
export const SOURCE = 'canary-pr-guardian';
|
|
40
|
+
const REF_SAFE = /[^A-Za-z0-9._-]/g;
|
|
41
|
+
const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
|
|
42
|
+
// The loud-fallback notices carry an em-dash (U+2014) as output data; written as
|
|
43
|
+
// an escape to honor the ASCII-source rule.
|
|
44
|
+
const EM_DASH = '\u{2014}';
|
|
45
|
+
/**
|
|
46
|
+
* Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
|
|
47
|
+
* Python's `json.dumps(..., ensure_ascii=True)` (the library default).
|
|
48
|
+
*/
|
|
49
|
+
function ensureAscii(json) {
|
|
50
|
+
// Escape every UTF-16 code UNIT >= 0x80 to \uXXXX, matching Python
|
|
51
|
+
// json.dumps(ensure_ascii=True). Iterating by unit (not code point) means an
|
|
52
|
+
// astral char's surrogate pair emits \udXXX\udXXX, like Python; a code-point
|
|
53
|
+
// regex would stop at U+FFFF and leave astral chars raw.
|
|
54
|
+
let out = '';
|
|
55
|
+
for (let i = 0; i < json.length; i++) {
|
|
56
|
+
const c = json.charCodeAt(i);
|
|
57
|
+
out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Trim leading/trailing `-` characters (Python `str.strip("-")`). */
|
|
62
|
+
function stripDashes(value) {
|
|
63
|
+
return value.replace(/^-+/, '').replace(/-+$/, '');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `<source>-<sanitized-ref>.json`; empty/blank ref -> `<source>-local.json`.
|
|
67
|
+
*
|
|
68
|
+
* A very long ref would produce a filename that trips `ENAMETOOLONG` (always
|
|
69
|
+
* degrades, never uses the channel), so the sanitized ref is truncated to
|
|
70
|
+
* `REF_MAX` chars with a short hash suffix appended to preserve uniqueness --
|
|
71
|
+
* only when truncation actually happens. Short refs are unchanged.
|
|
72
|
+
*/
|
|
73
|
+
export function analysisFilename(ref, source = SOURCE) {
|
|
74
|
+
let safe = stripDashes(ref.replace(REF_SAFE, '-')) || 'local';
|
|
75
|
+
if (safe.length > REF_MAX) {
|
|
76
|
+
// sha1 is a filename disambiguator (not a security digest); it matches the
|
|
77
|
+
// Python oracle's hashlib.sha1(ref)[:8] cache-filename contract and the
|
|
78
|
+
// input is a non-secret git ref. Changing it would break that byte contract.
|
|
79
|
+
const algo = 'sha1'; // harness-ignore SEC-CRY-001: filename hash, not crypto
|
|
80
|
+
const digest = createHash(algo)
|
|
81
|
+
.update(ref, 'utf-8')
|
|
82
|
+
.digest('hex')
|
|
83
|
+
.slice(0, 8);
|
|
84
|
+
safe = `${safe.slice(0, REF_MAX)}-${digest}`;
|
|
85
|
+
}
|
|
86
|
+
return `${source}-${safe}.json`;
|
|
87
|
+
}
|
|
88
|
+
/** ISO-8601 UTC timestamp with a `+00:00` offset (Python `isoformat`-shaped). */
|
|
89
|
+
function isoUtcNow() {
|
|
90
|
+
return new Date().toISOString().replace('Z', '+00:00');
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build the v1.0 envelope. `findings` is exactly `render(fmt='json')`'s array.
|
|
94
|
+
*/
|
|
95
|
+
export function buildAnalysisRecord(findings, args) {
|
|
96
|
+
const { ref, gate, effective_tier, degraded_notice, exit_code } = args;
|
|
97
|
+
const inner = JSON.parse(render(findings, 'json', effective_tier, degraded_notice));
|
|
98
|
+
const active = findings.filter((f) => !f.suppressed);
|
|
99
|
+
const suppressed = findings.filter((f) => f.suppressed);
|
|
100
|
+
const byFidelity = {};
|
|
101
|
+
for (const f of findings) {
|
|
102
|
+
byFidelity[f.fidelity] = (byFidelity[f.fidelity] ?? 0) + 1;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
schemaVersion: SCHEMA_VERSION,
|
|
106
|
+
source: SOURCE,
|
|
107
|
+
ref,
|
|
108
|
+
gate,
|
|
109
|
+
exitCode: exit_code,
|
|
110
|
+
tier: effective_tier,
|
|
111
|
+
degradedNotice: degraded_notice,
|
|
112
|
+
summary: {
|
|
113
|
+
total: findings.length,
|
|
114
|
+
unaddressed: active.length,
|
|
115
|
+
suppressed: suppressed.length,
|
|
116
|
+
byFidelity,
|
|
117
|
+
},
|
|
118
|
+
findings: inner.findings,
|
|
119
|
+
analyzedAt: args.analyzed_at ?? isoUtcNow(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Seams the tests spy: `buildAnalysisRecord` (Python monkeypatches
|
|
124
|
+
* `build_analysis_record`) and `replace` (Python monkeypatches `os.replace`).
|
|
125
|
+
* `emitAnalysis` calls them through this object so a `vi.spyOn(emitSeams, ...)`
|
|
126
|
+
* takes effect exactly like the Python `monkeypatch.setattr`.
|
|
127
|
+
*/
|
|
128
|
+
export const emitSeams = {
|
|
129
|
+
buildAnalysisRecord,
|
|
130
|
+
replace: (src, dst) => renameSync(src, dst),
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* True iff the harness home (`dirname(analysesDir)`, i.e. `.harness/`) exists.
|
|
134
|
+
*
|
|
135
|
+
* The analyses dir itself is created on demand by {@link emitAnalysis} (mirroring
|
|
136
|
+
* harness `AnalysisArchive.save`'s recursive `mkdir`).
|
|
137
|
+
*/
|
|
138
|
+
export function channelAvailable(analysesDir) {
|
|
139
|
+
try {
|
|
140
|
+
return statSync(dirname(analysesDir)).isDirectory();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Write one record to `analysesDir/<filename>`.
|
|
148
|
+
*
|
|
149
|
+
* On an absent channel or an I/O error (read-only FS, permission), return an
|
|
150
|
+
* `'unavailable'` {@link EmitResult} carrying a loud notice -- never silently
|
|
151
|
+
* drop the record (SC-10 fallback).
|
|
152
|
+
*/
|
|
153
|
+
export function emitAnalysis(findings, args) {
|
|
154
|
+
const { analysesDir } = args;
|
|
155
|
+
if (!channelAvailable(analysesDir)) {
|
|
156
|
+
return {
|
|
157
|
+
action: 'unavailable',
|
|
158
|
+
path: null,
|
|
159
|
+
notice: 'guardian: harness analyses channel unavailable ' +
|
|
160
|
+
`(.harness/ absent) ${EM_DASH} falling back to the sticky comment`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const target = join(analysesDir, analysisFilename(args.ref));
|
|
164
|
+
try {
|
|
165
|
+
// Build INSIDE the try: a non-I/O error (from JSON/render) would otherwise
|
|
166
|
+
// crash pr-check instead of degrading.
|
|
167
|
+
const record = emitSeams.buildAnalysisRecord(findings, args);
|
|
168
|
+
mkdirSync(analysesDir, { recursive: true });
|
|
169
|
+
// Atomic write: stage into a same-dir temp then rename (atomic on one
|
|
170
|
+
// filesystem). A torn/partial file would break the harness consumer.
|
|
171
|
+
const tmp = join(analysesDir, `.tmp-${randomBytes(8).toString('hex')}.json`);
|
|
172
|
+
writeFileSync(tmp, ensureAscii(JSON.stringify(record, null, 2)), 'utf-8');
|
|
173
|
+
try {
|
|
174
|
+
emitSeams.replace(tmp, target);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
// Clean up the staged temp so a failed rename leaves no leftover.
|
|
178
|
+
try {
|
|
179
|
+
unlinkSync(tmp);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// best-effort
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (exc) {
|
|
188
|
+
// Broadened beyond I/O: any build/render/write failure degrades to the LOUD
|
|
189
|
+
// fallback rather than crashing pr-check (the CLI turns this notice into a
|
|
190
|
+
// `::warning::` + sticky comment).
|
|
191
|
+
const message = exc instanceof Error ? exc.message : String(exc);
|
|
192
|
+
return {
|
|
193
|
+
action: 'unavailable',
|
|
194
|
+
path: null,
|
|
195
|
+
notice: `guardian: analyses build/write failed (${message}) ${EM_DASH} ` +
|
|
196
|
+
'falling back to the sticky comment',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { action: 'emitted', path: String(target), notice: null };
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=analysis-emit.js.map
|