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.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -36,6 +36,7 @@
36
36
  import { spawnSync } from 'node:child_process';
37
37
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
38
38
  import { join } from 'node:path';
39
+ import { ensureAscii } from '../util/ensure-ascii.js';
39
40
  // ---------------------------------------------------------------------------
40
41
  // Python-compatibility helpers (copied locally per-module, matching reporter.ts)
41
42
  // ---------------------------------------------------------------------------
@@ -58,13 +59,6 @@ function pyTruthy(value) {
58
59
  function pyGet(obj, key, fallback) {
59
60
  return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
60
61
  }
61
- /**
62
- * Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
63
- * on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`.
64
- */
65
- function ensureAscii(json) {
66
- return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
67
- }
68
62
  /** Python `str[:n]` by CODE POINT (never splits a surrogate pair). */
69
63
  function codePointSlice(s, n) {
70
64
  return [...s].slice(0, n).join('');
@@ -120,7 +114,7 @@ export const defaultSubprocess = (cmd, opts = {}) => {
120
114
  // ---------------------------------------------------------------------------
121
115
  // Schema
122
116
  // ---------------------------------------------------------------------------
123
- export const SCHEMA_VERSION = 'https://github.com/bop-clocktower/canary/schemas/workflow-mapping/v1';
117
+ const SCHEMA_VERSION = 'https://github.com/bop-clocktower/canary/schemas/workflow-mapping/v1';
124
118
  // Word-list used for automatic semantic-role heuristics.
125
119
  const ROLE_TRIGGERS = {
126
120
  qa_passed: ['qa pass', 'qa passed', 'qa done', 'tested', 'verified'],
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync, readdirSync } from 'node:fs';
10
10
  import { join, relative, sep } from 'node:path';
11
- import { _CONFIG_PROBES, inferPlaywrightShape, probe, } from './framework-probes.js';
11
+ import { CONFIG_PROBES, inferPlaywrightTestType, probeFramework, } from './framework-probes.js';
12
12
  import { comparePathParts, globDirs, isFile, parseJsonOrNull, readTextOrNull, } from './fs-glob.js';
13
13
  /**
14
14
  * Workspace package globs declared at *root*, from pnpm-workspace.yaml or
@@ -146,13 +146,13 @@ function toPosixRel(root, dir) {
146
146
  function configFindings(dir, rel) {
147
147
  const out = [];
148
148
  const seen = new Set();
149
- for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
149
+ for (const [filename, framework, shape, confidence] of CONFIG_PROBES) {
150
150
  if (!isFile(join(dir, filename)))
151
151
  continue;
152
152
  // Mirrors the config tier's own refinement: a playwright config with no
153
153
  // UI-fixture spec is an API suite, and the shape decides which skills ship.
154
154
  const refined = framework === 'playwright' && shape === 'e2e_ui'
155
- ? inferPlaywrightShape(dir)
155
+ ? inferPlaywrightTestType(dir)
156
156
  : shape;
157
157
  // NUL separates the parts because it cannot occur in a path, a framework
158
158
  // name, or a shape -- so `a\0b` can never collide with `a` + `\0b`. Written
@@ -177,14 +177,15 @@ function configFindings(dir, rel) {
177
177
  * Findings for one package: every config-tier match, or -- only when the config
178
178
  * tier found nothing at all -- a single content-tier answer.
179
179
  *
180
- * The language tier is deliberately withheld; see `probe` for why inheriting a
181
- * root `language:` per package would invent findings (#504 part 1, spec test 8).
180
+ * The language tier is deliberately withheld; see `probeFramework` for why
181
+ * inheriting a root `language:` per package would invent findings (#504 part 1,
182
+ * spec test 8).
182
183
  */
183
184
  function probePackage(dir, config, rel) {
184
185
  const fromConfig = configFindings(dir, rel);
185
186
  if (fromConfig.length > 0)
186
187
  return fromConfig;
187
- const [framework, shape, source, confidence] = probe(dir, config, ['content']);
188
+ const [framework, shape, source, confidence] = probeFramework(dir, config, ['content']);
188
189
  return framework === null
189
190
  ? []
190
191
  : [{ dir: rel, framework, shape, source, confidence }];
@@ -0,0 +1,36 @@
1
+ {
2
+ "version": 1,
3
+ "fallback": "junior",
4
+ "minDetectionConfidence": 0.5,
5
+ "minDetectionSignals": 2,
6
+ "detectionMap": {
7
+ "sdet": "sdet",
8
+ "manual": "manual"
9
+ },
10
+ "personas": [
11
+ {
12
+ "id": "sdet",
13
+ "label": "Senior SDET",
14
+ "audience": "Writes and owns automated tests daily; fluent in the framework and the codebase.",
15
+ "depth": "terse",
16
+ "formats": ["bullets", "code"],
17
+ "reasoning": false
18
+ },
19
+ {
20
+ "id": "junior",
21
+ "label": "Junior SDET",
22
+ "audience": "Writes automated tests but is still building judgement about which ones matter.",
23
+ "depth": "brief",
24
+ "formats": ["bullets", "code", "rationale"],
25
+ "reasoning": true
26
+ },
27
+ {
28
+ "id": "manual",
29
+ "label": "Manual tester",
30
+ "audience": "Tests by hand and reads test output; may not read or write the framework's code.",
31
+ "depth": "guided",
32
+ "formats": ["numbered-steps", "rationale"],
33
+ "reasoning": true
34
+ }
35
+ ]
36
+ }
@@ -162,9 +162,9 @@ export function tallyAdjudications(reactions) {
162
162
  const FINDING_ROW_RE = /^\|[^|]*\|\s*\[?`([^`]+)`/;
163
163
  /**
164
164
  * Extract the file paths of the ACTIVE findings shown in a sticky-comment body
165
- * (PURE). Reads the rendered table `render(fmt='comment')` emitted — this is
166
- * deliberately parsing the exact body reviewers reacted to, not the current
167
- * finding set, so a reaction is attributed to what the reviewer actually saw.
165
+ * (PURE). Reads the rendered table `renderFindings(fmt='comment')` emitted —
166
+ * this is deliberately parsing the exact body reviewers reacted to, not the
167
+ * current finding set, so a reaction is attributed to what the reviewer saw.
168
168
  * Returns `[]` for a no-gaps body (no table).
169
169
  */
170
170
  export function activeFindingPaths(commentBody) {
@@ -204,7 +204,7 @@ export function adjudicationFilename(prNumber) {
204
204
  return `${ADJUDICATION_SOURCE}-pr-${prNumber}.json`;
205
205
  }
206
206
  /** True iff the harness home (`dirname(analysesDir)`) exists. */
207
- function channelAvailable(analysesDir) {
207
+ function isChannelAvailable(analysesDir) {
208
208
  try {
209
209
  return statSync(dirname(analysesDir)).isDirectory();
210
210
  }
@@ -241,7 +241,7 @@ export async function collectAdjudications(client, args) {
241
241
  tally,
242
242
  collectedAt: args.collectedAt,
243
243
  });
244
- if (!channelAvailable(args.analysesDir)) {
244
+ if (!isChannelAvailable(args.analysesDir)) {
245
245
  return {
246
246
  action: 'unavailable',
247
247
  path: null,
@@ -14,7 +14,7 @@
14
14
  * canary's records -- they never clobber a harness record and always pass
15
15
  * `AnalysisArchive.safePath` (no traversal).
16
16
  * - {@link buildAnalysisRecord} -- the v1.0 envelope wrapping the verbatim
17
- * `render(fmt="json")` findings array.
17
+ * `renderFindings(fmt="json")` findings array.
18
18
  *
19
19
  * SC-11 boundary: this module is deterministic filesystem/JSON only. It imports
20
20
  * no `AgentTier`/LLM-SDK module (only intra-guardian, agent-free helpers).
@@ -34,36 +34,25 @@
34
34
  import { createHash, randomBytes } from 'node:crypto';
35
35
  import { mkdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
36
36
  import { dirname, join } from 'node:path';
37
+ import { ensureAscii } from '../util/ensure-ascii.js';
37
38
  import { coverageDegradedNotice, coverageStatus, } from './coverage.js';
38
- import { combineNotices, render } from './pr-check.js';
39
+ import { combineNotices, renderFindings, } from './pr-check.js';
39
40
  // 1.1 adds the additive `coverage` block (#554); readers of 1.0 are unaffected.
40
41
  // 1.2 adds the additive `skipped` list (#582). Additive again, and bumped again
41
42
  // for the reason recorded in #572: a reader that pins a version must be able to
42
43
  // tell which fields it can rely on being present, and silence about a new field
43
44
  // is indistinguishable from the field being absent for a real reason.
44
- export const SCHEMA_VERSION = '1.2';
45
- export const SOURCE = 'canary-pr-guardian';
45
+ // 1.3 adds the additive `provenance` block (#761). Same additive rule. This is
46
+ // the field an archived record needs most: when a run is questioned WEEKS later
47
+ // from its uploaded artifact, `checked: 43` is only interpretable next to the
48
+ // diff endpoints that produced it.
49
+ export const SCHEMA_VERSION = '1.3';
50
+ const ANALYSIS_SOURCE = 'canary-pr-guardian';
46
51
  const REF_SAFE = /[^A-Za-z0-9._-]/g;
47
52
  const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
48
53
  // The loud-fallback notices carry an em-dash (U+2014) as output data; written as
49
54
  // an escape to honor the ASCII-source rule.
50
55
  const EM_DASH = '\u{2014}';
51
- /**
52
- * Escape every non-ASCII (>= U+0080) code unit to a `\uXXXX` sequence, matching
53
- * Python's `json.dumps(..., ensure_ascii=True)` (the library default).
54
- */
55
- function ensureAscii(json) {
56
- // Escape every UTF-16 code UNIT >= 0x80 to \uXXXX, matching Python
57
- // json.dumps(ensure_ascii=True). Iterating by unit (not code point) means an
58
- // astral char's surrogate pair emits \udXXX\udXXX, like Python; a code-point
59
- // regex would stop at U+FFFF and leave astral chars raw.
60
- let out = '';
61
- for (let i = 0; i < json.length; i++) {
62
- const c = json.charCodeAt(i);
63
- out += c >= 0x80 ? '\\u' + c.toString(16).padStart(4, '0') : json[i];
64
- }
65
- return out;
66
- }
67
56
  /** Trim leading/trailing `-` characters (Python `str.strip("-")`). */
68
57
  function stripDashes(value) {
69
58
  return value.replace(/^-+/, '').replace(/-+$/, '');
@@ -76,7 +65,7 @@ function stripDashes(value) {
76
65
  * `REF_MAX` chars with a short hash suffix appended to preserve uniqueness --
77
66
  * only when truncation actually happens. Short refs are unchanged.
78
67
  */
79
- export function analysisFilename(ref, source = SOURCE) {
68
+ export function analysisFilename(ref, source = ANALYSIS_SOURCE) {
80
69
  let safe = stripDashes(ref.replace(REF_SAFE, '-')) || 'local';
81
70
  if (safe.length > REF_MAX) {
82
71
  // sha1 is a filename disambiguator (not a security digest); it matches the
@@ -96,7 +85,8 @@ function isoUtcNow() {
96
85
  return new Date().toISOString().replace('Z', '+00:00');
97
86
  }
98
87
  /**
99
- * Build the v1.0 envelope. `findings` is exactly `render(fmt='json')`'s array.
88
+ * Build the v1.0 envelope. `findings` is exactly the array from
89
+ * `renderFindings(fmt='json')`.
100
90
  */
101
91
  export function buildAnalysisRecord(findings, args) {
102
92
  const { ref, gate, effective_tier, degraded_notice, exit_code } = args;
@@ -104,7 +94,7 @@ export function buildAnalysisRecord(findings, args) {
104
94
  // input's — so "no findings" can never be read as "coverage said so".
105
95
  const coverage = args.coverage ?? null;
106
96
  const notice = combineNotices(degraded_notice, coverage ? coverageDegradedNotice(coverage) : null);
107
- const inner = JSON.parse(render(findings, 'json', effective_tier, notice));
97
+ const inner = JSON.parse(renderFindings(findings, 'json', effective_tier, notice));
108
98
  const active = findings.filter((f) => !f.suppressed);
109
99
  const suppressed = findings.filter((f) => f.suppressed);
110
100
  const byFidelity = {};
@@ -113,7 +103,7 @@ export function buildAnalysisRecord(findings, args) {
113
103
  }
114
104
  return {
115
105
  schemaVersion: SCHEMA_VERSION,
116
- source: SOURCE,
106
+ source: ANALYSIS_SOURCE,
117
107
  ref,
118
108
  gate,
119
109
  exitCode: exit_code,
@@ -125,6 +115,7 @@ export function buildAnalysisRecord(findings, args) {
125
115
  ? null
126
116
  : { status: coverageStatus(coverage), ...coverage },
127
117
  skipped: args.skipped ?? [],
118
+ provenance: args.provenance ?? null,
128
119
  summary: {
129
120
  total: findings.length,
130
121
  unaddressed: active.length,
@@ -151,7 +142,7 @@ export const emitSeams = {
151
142
  * The analyses dir itself is created on demand by {@link emitAnalysis} (mirroring
152
143
  * harness `AnalysisArchive.save`'s recursive `mkdir`).
153
144
  */
154
- export function channelAvailable(analysesDir) {
145
+ export function isChannelAvailable(analysesDir) {
155
146
  try {
156
147
  return statSync(dirname(analysesDir)).isDirectory();
157
148
  }
@@ -168,7 +159,7 @@ export function channelAvailable(analysesDir) {
168
159
  */
169
160
  export function emitAnalysis(findings, args) {
170
161
  const { analysesDir } = args;
171
- if (!channelAvailable(analysesDir)) {
162
+ if (!isChannelAvailable(analysesDir)) {
172
163
  return {
173
164
  action: 'unavailable',
174
165
  path: null,
@@ -178,8 +169,8 @@ export function emitAnalysis(findings, args) {
178
169
  }
179
170
  const target = join(analysesDir, analysisFilename(args.ref));
180
171
  try {
181
- // Build INSIDE the try: a non-I/O error (from JSON/render) would otherwise
182
- // crash pr-check instead of degrading.
172
+ // Build INSIDE the try: a non-I/O error (from JSON/renderFindings) would
173
+ // otherwise crash pr-check instead of degrading.
183
174
  const record = emitSeams.buildAnalysisRecord(findings, args);
184
175
  mkdirSync(analysesDir, { recursive: true });
185
176
  // Atomic write: stage into a same-dir temp then rename (atomic on one