@event4u/agent-config 9.18.0 → 9.19.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.
@@ -277,6 +277,10 @@ export function main(argv?: readonly string[]): number {
277
277
  process.stderr.write(`❌ ${exc instanceof Error ? exc.message : String(exc)}\n`);
278
278
  return 2;
279
279
  }
280
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
281
+ // asserted count machine-readably. Written to stderr because `--format json`
282
+ // owns stdout here — a count on stdout would corrupt the JSON payload.
283
+ process.stderr.write(`scanned: ${String(targets.length)}\n`);
280
284
 
281
285
  const all_violations: Violation[] = [];
282
286
  for (const p of targets) {
@@ -11,8 +11,14 @@
11
11
  * commit types + rule/schema diffs, honest-null markers, removed public
12
12
  * surface) and FAILS when a populated generated category meets a `_none_`
13
13
  * curated field. It blocks the contradiction only — a human still writes the
14
- * prose; nothing here auto-formulates highlights, and a filled field is
15
- * never judged for quality.
14
+ * prose, and a filled field is never judged for quality.
15
+ *
16
+ * The classifier lives in `_lib/release_highlights.ts` and is shared with the
17
+ * generator, which now pre-fills each substantiated label instead of writing
18
+ * `_none_` everywhere. That removes the guaranteed first-run red (9.17.0,
19
+ * 9.18.0) without blunting this gate: a machine-written `_none_` was never the
20
+ * failure it was built for — a HUMAN writing one was (9.13.0, 9.14.0), and that
21
+ * still fails here. An unrewritten derived line warns; it never blocks.
16
22
  *
17
23
  * Derivation is deliberately conservative (documented per-label below):
18
24
  * a false red makes every release annoying; a miss only returns the head to
@@ -25,96 +31,41 @@
25
31
  * `--from` defaults to the latest reachable release tag before `--to`
26
32
  * (default HEAD). Exit codes: 0 plausible · 1 contradiction · 2 usage error.
27
33
  */
28
- import { spawnSync } from 'node:child_process';
29
34
  import * as fs from 'node:fs';
30
35
  import * as path from 'node:path';
31
36
  import process from 'node:process';
32
37
  import { fileURLToPath } from 'node:url';
33
38
 
39
+ import {
40
+ HEAD_LABELS,
41
+ HEAD_NONE,
42
+ type SpanCommit,
43
+ collect_span_commits,
44
+ derive_categories,
45
+ parse_git_log,
46
+ previous_release_tag,
47
+ stale_draft_labels,
48
+ } from './_lib/release_highlights.js';
34
49
  import { extract_changelog_section } from './_lib/release_material.js';
35
50
 
36
51
  const _HERE = fileURLToPath(import.meta.url);
37
52
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
38
53
 
39
- // The five curated labels, mirrored from release.ts RELEASE_HEAD_SECTIONS
40
- // (kept literal here so this checker has no import edge into the release
41
- // pipeline it audits; the unit tests pin the two lists against each other).
42
- export const HEAD_LABELS: readonly string[] = [
43
- 'Behaviour changes',
44
- 'Default changes + migration',
45
- 'Security and correctness',
46
- 'Honest nulls',
47
- 'Known limitations',
48
- ];
49
-
50
- export const HEAD_NONE = '_none_';
51
-
52
- export interface SpanCommit {
53
- sha: string;
54
- subject: string;
55
- body: string;
56
- /** `A` / `M` / `D` / `R…` status per touched path. */
57
- files: ReadonlyArray<{ status: string; path: string }>;
58
- breaking: boolean;
59
- }
60
-
61
- /** Public artefact trees whose deletions count as removed public surface. */
62
- const _PUBLIC_SURFACE_PREFIXES = [
63
- 'src/skills/',
64
- 'src/rules/',
65
- 'src/agent-src/commands/',
66
- 'src/domains/',
67
- ];
68
-
69
- const _RULE_OR_SCHEMA_PREFIXES = ['src/rules/', 'src/scripts/schemas/'];
70
-
71
- /**
72
- * Derive evidence per curated label from the span. Rules per label:
73
- *
74
- * - Security and correctness: conventional scope matching /secur/i, or the
75
- * whole word "security" in the subject.
76
- * - Behaviour changes: breaking (`!` / BREAKING CHANGE) commits, diffs
77
- * touching `src/rules/` or `src/scripts/schemas/`, and deletions under the
78
- * public artefact trees (removed public surface).
79
- * - Default changes + migration: subject carrying the whole word "default",
80
- * "migration", or "migrate".
81
- * - Honest nulls: subject or body carrying an "honest null" marker.
82
- * - Known limitations: never derived — pure prose, not gate-checkable.
83
- */
84
- export function derive_categories(commits: readonly SpanCommit[]): Record<string, string[]> {
85
- const out: Record<string, string[]> = {};
86
- for (const label of HEAD_LABELS) {
87
- out[label] = [];
88
- }
89
- const evidence = (c: SpanCommit): string => `${c.sha.slice(0, 7)} ${c.subject}`;
90
- for (const c of commits) {
91
- const scopeMatch = /^\w+\(([^)]*)\)!?:/u.exec(c.subject);
92
- const scope = scopeMatch ? scopeMatch[1]! : '';
93
- if (/secur/iu.test(scope) || /\bsecurity\b/iu.test(c.subject)) {
94
- out['Security and correctness']!.push(evidence(c));
95
- }
96
- const removedPublic = c.files.filter(
97
- (f) => f.status.startsWith('D') && _PUBLIC_SURFACE_PREFIXES.some((p) => f.path.startsWith(p)),
98
- );
99
- const ruleOrSchema = c.files.some((f) =>
100
- _RULE_OR_SCHEMA_PREFIXES.some((p) => f.path.startsWith(p)),
101
- );
102
- if (c.breaking || ruleOrSchema || removedPublic.length > 0) {
103
- const detail =
104
- removedPublic.length > 0
105
- ? `${evidence(c)} (removes ${removedPublic.map((f) => f.path).join(', ')})`
106
- : evidence(c);
107
- out['Behaviour changes']!.push(detail);
108
- }
109
- if (/\b(default|migration|migrate)\b/iu.test(c.subject)) {
110
- out['Default changes + migration']!.push(evidence(c));
111
- }
112
- if (/honest[ -]null/iu.test(`${c.subject}\n${c.body}`)) {
113
- out['Honest nulls']!.push(evidence(c));
114
- }
115
- }
116
- return out;
117
- }
54
+ // Labels and derivation live in `_lib/release_highlights.ts` — ONE definition
55
+ // shared with the generator that pre-fills the head. They used to be duplicated
56
+ // here to keep the checker independent of the release pipeline, and that
57
+ // independence is what produced the defect this gate now guards against from
58
+ // the other side: the generator wrote `_none_` into every field while this
59
+ // checker rejected exactly that, so every release PR was red on its first run.
60
+ // Sharing the classifier does not blunt the gate — it still blocks the failure
61
+ // it was built for, a human editing a substantiated line back to `_none_`.
62
+ export {
63
+ HEAD_LABELS,
64
+ HEAD_NONE,
65
+ derive_categories,
66
+ parse_git_log as _parse_git_log,
67
+ type SpanCommit,
68
+ };
118
69
 
119
70
  /**
120
71
  * Parse the curated head from a changelog section body. Returns null when
@@ -162,74 +113,6 @@ export function highlight_contradictions(
162
113
  return out;
163
114
  }
164
115
 
165
- // ─── git span collection (CLI only — the core above is pure) ────────────────
166
-
167
- const _RECORD = '\u001e';
168
- const _FIELD = '\u001f';
169
-
170
- export function _parse_git_log(raw: string): SpanCommit[] {
171
- const commits: SpanCommit[] = [];
172
- for (const record of raw.split(_RECORD)) {
173
- if (!record.trim()) {
174
- continue;
175
- }
176
- const [head, ...restLines] = record.split('\n');
177
- const parts = (head ?? '').split(_FIELD);
178
- if (parts.length < 3) {
179
- continue;
180
- }
181
- const sha = parts[0]!.trim();
182
- const subject = parts[1]!;
183
- const bodyParts: string[] = [parts.slice(2).join(_FIELD)];
184
- const files: Array<{ status: string; path: string }> = [];
185
- for (const line of restLines) {
186
- const m = /^([A-Z]\d*)\t(.+)$/u.exec(line);
187
- if (m) {
188
- // Renames carry two paths; the last tab field is the new path,
189
- // the first the old — record both so a rename out of a public
190
- // tree still counts its origin path.
191
- const segs = m[2]!.split('\t');
192
- for (const p of segs) {
193
- files.push({ status: m[1]!, path: p });
194
- }
195
- } else {
196
- // %b is multi-line — everything that is not a name-status
197
- // line is body continuation (honest-null markers live there).
198
- bodyParts.push(line);
199
- }
200
- }
201
- const body = bodyParts.join('\n');
202
- commits.push({
203
- sha,
204
- subject,
205
- body,
206
- files,
207
- breaking: /^\w+(\([^)]*\))?!:/u.test(subject) || /BREAKING CHANGE/u.test(body),
208
- });
209
- }
210
- return commits;
211
- }
212
-
213
- function _git(args: string[]): string {
214
- const r = spawnSync('git', args, { encoding: 'utf-8', cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 });
215
- if (r.status !== 0) {
216
- process.stderr.write(`git ${args.join(' ')} failed: ${r.stderr}\n`);
217
- process.exit(2);
218
- }
219
- return r.stdout;
220
- }
221
-
222
- function _collect_span(from: string, to: string): SpanCommit[] {
223
- const raw = _git([
224
- 'log',
225
- `${from}..${to}`,
226
- '--no-merges',
227
- '--name-status',
228
- '--format=%x1e%H%x1f%s%x1f%b',
229
- ]);
230
- return _parse_git_log(raw);
231
- }
232
-
233
116
  function main(argv: readonly string[]): number {
234
117
  let version: string | null = null;
235
118
  let from: string | null = null;
@@ -251,7 +134,13 @@ function main(argv: readonly string[]): number {
251
134
  return 2;
252
135
  }
253
136
  if (!from) {
254
- from = _git(['describe', '--tags', '--abbrev=0', '--match', '[0-9]*.[0-9]*.[0-9]*', `${to}^`]).trim();
137
+ from = previous_release_tag(to, REPO_ROOT);
138
+ if (!from) {
139
+ process.stderr.write(
140
+ `no release tag reachable before ${to} — pass --from explicitly\n`,
141
+ );
142
+ return 2;
143
+ }
255
144
  }
256
145
  const section = extract_changelog_section(fs.readFileSync(changelogPath, 'utf-8'), version);
257
146
  if (!section) {
@@ -263,7 +152,26 @@ function main(argv: readonly string[]): number {
263
152
  process.stdout.write(`ℹ️ no curated head in the ${version} section — nothing to check\n`);
264
153
  return 0;
265
154
  }
266
- const derived = derive_categories(_collect_span(from, to));
155
+ let span: SpanCommit[];
156
+ try {
157
+ span = collect_span_commits(from, to, REPO_ROOT);
158
+ } catch (err) {
159
+ process.stderr.write(`${(err as Error).message}\n`);
160
+ return 2;
161
+ }
162
+ // A generator-derived line that nobody rewrote is a prose gap, never a
163
+ // contradiction: the line's evidence is true, it is just unpolished. Warn
164
+ // so the omission is visible, and keep the exit code owned solely by the
165
+ // `_none_` check — a warning that reds the build is the guaranteed-red
166
+ // failure mode this whole change exists to remove.
167
+ const drafts = stale_draft_labels(curated);
168
+ if (drafts.length > 0) {
169
+ process.stdout.write(
170
+ `⚠️ auto-derived head line(s) not yet rewritten for ${version}: ` +
171
+ `${drafts.join(', ')} — advisory, not blocking.\n`,
172
+ );
173
+ }
174
+ const derived = derive_categories(span);
267
175
  const contradictions = highlight_contradictions(curated, derived);
268
176
  if (contradictions.length === 0) {
269
177
  process.stdout.write(`✅ curated head plausible for ${version} (span ${from}..${to})\n`);
@@ -437,11 +437,12 @@ const CI_BRANCH_ENV_KEYS = ['GITHUB_HEAD_REF', 'GITHUB_REF_NAME'] as const;
437
437
  * uses it to decide whether a leftover artifact in the reviews directory is
438
438
  * THIS branch's (and may therefore produce violations) or a foreign one.
439
439
  *
440
- * The CI environment is consulted FIRST because on a `pull_request` checkout
441
- * `HEAD` is a detached synthetic merge commit: `rev-parse --abbrev-ref HEAD`
442
- * yields `HEAD`, the slug degrades to `detached-<sha>`, and no artefact can
443
- * ever be "own" — which inverts contract §2.6 on the layer the contract calls
444
- * authoritative (an own malformed artefact would be reported as
440
+ * Resolution order is **git first, CI environment as the fallback** — see the
441
+ * reason inline below. The env fallback exists because on a `pull_request`
442
+ * checkout `HEAD` is a detached synthetic merge commit: `rev-parse --abbrev-ref
443
+ * HEAD` yields `HEAD`, the slug would degrade to `detached-<sha>`, and no
444
+ * artefact could ever be "own" — inverting contract §2.6 on the layer the
445
+ * contract calls authoritative (an own malformed artefact would report
445
446
  * `missing-artifact` instead of the root-cause `bad-marker`, and `stale-review`
446
447
  * would never fire in CI). A `HEAD` / `detached-*` env value carries no branch
447
448
  * identity either and is ignored.
@@ -359,6 +359,10 @@ function main(argv?: readonly string[]): number {
359
359
  process.stderr.write(`❌ ${exc.message}\n`);
360
360
  return 2;
361
361
  }
362
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
363
+ // asserted count machine-readably, before the verdict branches, so a run
364
+ // that finds errors still reports the corpus it read.
365
+ process.stdout.write(`scanned: ${String(artefacts.length)}\n`);
362
366
  const allErrs: string[] = [];
363
367
  for (const p of artefacts) {
364
368
  allErrs.push(..._check_one(p, wsIds, packIds, quarantine));
@@ -362,7 +362,13 @@ export function validate_handoff_artifact(text: string): string[] {
362
362
 
363
363
  export function main(argv?: readonly string[]): number {
364
364
  let skills_dir = SKILLS_DIR;
365
- const args = argv ?? process.argv.slice(2);
365
+ // Positional-only: flags are NOT paths. Before this filter the CI invocation
366
+ // (`lint_handoffs --quiet`, injected by Taskfile's QUIET_FLAG) resolved
367
+ // `--quiet` as the skills root, scanned 0 files and exited 2 — the gate was
368
+ // red under the exact argv CI runs while green when probed bare. That is the
369
+ // inverse of this suite's dead-scope defect and the reason gate-coverage
370
+ // rule 2 pins CI-identical argv.
371
+ const args = (argv ?? process.argv.slice(2)).filter((a) => !String(a).startsWith('-'));
366
372
  if (args.length > 0 && String(args[0]).endsWith('HANDOFF.md')) {
367
373
  // artifact mode — validate the workflow-resume file's required fields
368
374
  const text = fs.readFileSync(_resolve(args[0] as string), 'utf-8');
@@ -399,6 +405,12 @@ export function main(argv?: readonly string[]): number {
399
405
  process.stderr.write(`❌ ${exc.message}\n`);
400
406
  return 2;
401
407
  }
408
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
409
+ // count the assertion above just validated. Emitted before the verdict
410
+ // branches so a run WITH violations still reports its corpus — coverage and
411
+ // verdict are different questions — and outside the QUIET guard, because CI
412
+ // passes --quiet and a count only visible without it is not a count.
413
+ process.stdout.write(`scanned: ${String(scanned)}\n`);
402
414
 
403
415
  const violations = lint(skills_dir);
404
416
  if (violations.length === 0) {
@@ -347,6 +347,11 @@ function main(argv?: readonly string[]): number {
347
347
  process.stderr.write(`❌ ${exc.message}\n`);
348
348
  return 2;
349
349
  }
350
+ // Gate-coverage contract (src/config/gate-coverage.yml rule 1): publish the
351
+ // asserted count machine-readably. The human line below is quiet-suppressed
352
+ // and only prints on the clean path; this one prints on both, because a gate
353
+ // that found issues still scanned its corpus.
354
+ process.stdout.write(`scanned: ${String(checked)}\n`);
350
355
  if (issues) {
351
356
  process.stderr.write(`BASELINE: ${issues} issue(s) across ${checked} name(s)\n`);
352
357
  return 1;
@@ -118,6 +118,12 @@ import {
118
118
  perform_split,
119
119
  plan_split,
120
120
  } from './_lib/changelog_eras.js';
121
+ import {
122
+ HEAD_LABELS,
123
+ collect_span_commits,
124
+ derive_category_hits,
125
+ render_derived_head_values,
126
+ } from './_lib/release_highlights.js';
121
127
  import {
122
128
  extract_changelog_section,
123
129
  pr_body_from_section,
@@ -383,13 +389,7 @@ const SECTIONS: ReadonlyArray<readonly [string, string | null, readonly string[]
383
389
  * so the package does not end up with two competing definitions of a curated
384
390
  * head.
385
391
  */
386
- const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
387
- 'Behaviour changes',
388
- 'Default changes + migration',
389
- 'Security and correctness',
390
- 'Honest nulls',
391
- 'Known limitations',
392
- ];
392
+ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = HEAD_LABELS;
393
393
 
394
394
  /**
395
395
  * Cap on the head, in rendered lines.
@@ -402,16 +402,44 @@ const RELEASE_HEAD_SECTIONS: ReadonlyArray<string> = [
402
402
  export const RELEASE_HEAD_CAP_LINES = 10;
403
403
 
404
404
  /**
405
- * The default value of every head line.
405
+ * The fallback value of a head line the span does not substantiate.
406
+ *
407
+ * `_none_` and not a placeholder token: where nothing was derived it is the
408
+ * **true** answer, and a release that genuinely changed no defaults should say
409
+ * so rather than carry an unfilled marker.
406
410
  *
407
- * `_none_` and not a placeholder token: for most releases it is the **true**
408
- * answer, and a release that genuinely changed no defaults should say so
409
- * rather than carry an unfilled marker. That also keeps the skeleton free of
410
- * the placeholder prose `output-discipline` bans — there is nothing here that
411
- * is wrong-if-shipped, only something that is uninformative-if-unedited.
411
+ * It is a fallback and no longer a blanket default. Writing `_none_` into
412
+ * every field made the generator assert five things it had not checked, and
413
+ * `check_release_highlights` rejected exactly that assertion the moment the
414
+ * span contradicted it — which for this package is every release, because every
415
+ * release touches `src/rules/` or `src/scripts/schemas/`. Substantiated labels
416
+ * are now pre-filled from the span (`_derive_head_prefill`), so the tool states
417
+ * only what it can support and `_none_` means what it says.
412
418
  */
413
419
  const HEAD_DEFAULT = '_none_';
414
420
 
421
+ /**
422
+ * Pre-fill values for the curated head from the release span.
423
+ *
424
+ * Best-effort by design, same stance as the test-trend footer: a git failure
425
+ * degrades to the `_none_` skeleton rather than blocking a release. The gate
426
+ * that reads the result runs later, on the branch head, and derives from the
427
+ * same shared classifier — so a degraded run surfaces as the gate's ordinary
428
+ * "fill the head" message instead of a crash mid-release.
429
+ */
430
+ function _derive_head_prefill(prev: string | null): Record<string, string> {
431
+ try {
432
+ const span = collect_span_commits(prev, 'HEAD', REPO_ROOT);
433
+ return render_derived_head_values(derive_category_hits(span));
434
+ } catch (err) {
435
+ process.stderr.write(
436
+ `warning: could not derive release-head highlights (${(err as Error).message}); ` +
437
+ 'the head ships as the `_none_` skeleton\n',
438
+ );
439
+ return {};
440
+ }
441
+ }
442
+
415
443
  /**
416
444
  * Render the curated head. Emitted by the generator on every release so it
417
445
  * cannot be forgotten; edited by the maintainer before merge.
@@ -2344,8 +2372,14 @@ function main(argv: readonly string[] | null = null): number {
2344
2372
  // (~15s+), which `--dry-run exits 0 without needing …` contracts (and
2345
2373
  // the release test) assume never runs. Real releases compute it.
2346
2374
  const test_trend_line = args.dry_run ? null : _render_test_trend_line(prev);
2375
+ // Derived unconditionally, dry-run included: it is one `git log` (~25 ms),
2376
+ // not the trend line's full vitest collection. Skipping it on `--dry-run`
2377
+ // made the preview print the `_none_` skeleton while the real run wrote
2378
+ // pre-filled lines — a preview that contradicts its own output, which is
2379
+ // the exact class of surface disagreement this pre-fill exists to end.
2347
2380
  const [full, body] = render_changelog_entry(target, prev, commits, today, {
2348
2381
  test_trend_line,
2382
+ head: _derive_head_prefill(prev),
2349
2383
  });
2350
2384
 
2351
2385
  // Era-split planning — gate on the POST-release view (2026-07-07 fix).
@@ -372,7 +372,7 @@ export function analyze(src: string, repoRoot: string): Analysis {
372
372
  const confirmed: Finding[] = [];
373
373
  const unproven: Finding[] = [];
374
374
  const roots = new Map<string, string>();
375
- const missing = (rel: string): boolean => !fs.existsSync(path.join(repoRoot, rel));
375
+ const missing = (rel: string): boolean => !rootExists(repoRoot, rel);
376
376
 
377
377
  for (const [rel, e] of found) {
378
378
  roots.set(rel, [...e.names].join(',') || '(inline)');
@@ -549,12 +549,46 @@ export function censusOnlyRoots(src: string, repoRoot: string): Map<string, stri
549
549
  if (rel === '' || rel.startsWith('..')) continue;
550
550
  // `node_modules/.bin/tsx` is a tool path a gate SPAWNS, not a corpus it reads.
551
551
  if (rel === 'node_modules' || rel.startsWith('node_modules/')) continue;
552
- if (!fs.existsSync(path.join(repoRoot, rel))) continue; // the safety filter — see the doc comment
552
+ if (!rootExists(repoRoot, rel)) continue; // the safety filter — see the doc comment
553
553
  if (!out.has(rel)) out.set(rel, '(census-only)');
554
554
  }
555
555
  return out;
556
556
  }
557
557
 
558
+ /**
559
+ * Does a declared root exist, independent of checkout shape?
560
+ *
561
+ * A plain `existsSync` answers differently in a linked git worktree, where
562
+ * `.git` is a FILE containing `gitdir: …` rather than a directory — so a root
563
+ * like `.git/HEAD` resolves in a clone and vanishes in a worktree. The census
564
+ * has to "match a fresh run" to be worth committing, and a report whose rows
565
+ * depend on which checkout generated it cannot. Resolving `.git/*` through the
566
+ * real git directory makes the two agree.
567
+ */
568
+ export function resolveRoot(repoRoot: string, rel: string): string {
569
+ if (rel === '.git' || rel.startsWith('.git/')) {
570
+ const gitPath = path.join(repoRoot, '.git');
571
+ try {
572
+ if (fs.statSync(gitPath).isFile()) {
573
+ // `gitdir: <abs-or-relative path>` — the worktree's real git dir.
574
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
575
+ const target = m?.[1]?.trim();
576
+ if (target !== undefined && target !== '') {
577
+ const gitDir = path.isAbsolute(target) ? target : path.join(repoRoot, target);
578
+ return rel === '.git' ? gitDir : path.join(gitDir, rel.slice('.git/'.length));
579
+ }
580
+ }
581
+ } catch {
582
+ /* fall through to the plain join — a missing .git is genuinely absent */
583
+ }
584
+ }
585
+ return path.join(repoRoot, rel);
586
+ }
587
+
588
+ export function rootExists(repoRoot: string, rel: string): boolean {
589
+ return fs.existsSync(resolveRoot(repoRoot, rel));
590
+ }
591
+
558
592
  /** Directories never worth walking for a unit count. */
559
593
  const COUNT_SKIP: ReadonlySet<string> = new Set(['node_modules', '.git', '.venv', '__pycache__']);
560
594
 
@@ -578,7 +612,7 @@ export interface RootCount {
578
612
  * a container migration shows up as a diff instead of as silence.
579
613
  */
580
614
  export function countUnits(repoRoot: string, rel: string): RootCount {
581
- const abs = path.join(repoRoot, rel);
615
+ const abs = resolveRoot(repoRoot, rel);
582
616
  let st: fs.Stats;
583
617
  try {
584
618
  st = fs.statSync(abs);