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.
Files changed (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. 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