session-orchestrator 3.16.0 → 3.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { execFile as execFileCb } from 'node:child_process';
|
|
39
39
|
import { promisify } from 'node:util';
|
|
40
40
|
import { fingerprintFinding } from './fingerprint.mjs';
|
|
41
|
+
import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
|
|
41
42
|
|
|
42
43
|
const realExecFile = promisify(execFileCb);
|
|
43
44
|
|
|
@@ -293,6 +294,9 @@ function levenshtein(a, b) {
|
|
|
293
294
|
* @param {Set<string>} opts.existingFingerprints - fingerprints of issues already filed
|
|
294
295
|
* @param {boolean} [opts.dryRun] - if true, return command without spawning
|
|
295
296
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
297
|
+
* @param {string} [opts.project] - GitLab project path (passed via --repo if provided; #872: otherwise auto-detected)
|
|
298
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
299
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
296
300
|
* @returns {Promise<{action: 'create'|'noop', iid?: number, command?: string[]}>}
|
|
297
301
|
*/
|
|
298
302
|
export async function reconcileFinding({
|
|
@@ -300,6 +304,9 @@ export async function reconcileFinding({
|
|
|
300
304
|
existingFingerprints,
|
|
301
305
|
dryRun = false,
|
|
302
306
|
execFile: execFileOpt,
|
|
307
|
+
project,
|
|
308
|
+
repoRoot = process.cwd(),
|
|
309
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
303
310
|
}) {
|
|
304
311
|
// Resolve execFile — DI seam for tests; never caller-supplied binary path (ADR-364 §C5 HIGH fix).
|
|
305
312
|
const execFileFn = typeof execFileOpt === 'function' ? execFileOpt : realExecFile;
|
|
@@ -354,6 +361,18 @@ export async function reconcileFinding({
|
|
|
354
361
|
body,
|
|
355
362
|
];
|
|
356
363
|
|
|
364
|
+
// #872: explicit project always wins; otherwise auto-detect a --repo spec
|
|
365
|
+
// from the local git remotes (host-pinning — a bare glab spawn falls back
|
|
366
|
+
// to the ambient GITLAB_HOST, which can silently target the wrong
|
|
367
|
+
// instance on a multi-instance host).
|
|
368
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
369
|
+
if (effectiveProject !== undefined) {
|
|
370
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
371
|
+
throw new ReconcileError('project contains forbidden characters', 'VALIDATION');
|
|
372
|
+
}
|
|
373
|
+
cmd.push('--repo', effectiveProject);
|
|
374
|
+
}
|
|
375
|
+
|
|
357
376
|
if (dryRun) {
|
|
358
377
|
return { action: 'create', command: cmd };
|
|
359
378
|
}
|
|
@@ -385,10 +404,12 @@ export async function reconcileFinding({
|
|
|
385
404
|
* fingerprints from issue bodies for downstream dedup.
|
|
386
405
|
*
|
|
387
406
|
* @param {object} [opts]
|
|
388
|
-
* @param {string} [opts.project] - GitLab project path (passed via --repo if provided)
|
|
407
|
+
* @param {string} [opts.project] - GitLab project path (passed via --repo if provided; #872: otherwise auto-detected)
|
|
389
408
|
* @param {string} [opts.label='from:test-runner'] - label filter for the query
|
|
390
409
|
* @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB default, #389)
|
|
391
410
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
411
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
412
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
392
413
|
* @returns {Promise<
|
|
393
414
|
* {ok: true, issues: Array<{iid: number, title: string, body: string}>, fingerprints: Set<string>}
|
|
394
415
|
* | {ok: false, error: {code: string, message: string}}
|
|
@@ -399,6 +420,8 @@ export async function listExistingFindings({
|
|
|
399
420
|
label = 'from:test-runner',
|
|
400
421
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
401
422
|
execFile: execFileOpt,
|
|
423
|
+
repoRoot = process.cwd(),
|
|
424
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
402
425
|
} = {}) {
|
|
403
426
|
// Binary is always the allowlisted value — never caller-supplied (ADR-364 §C5 HIGH fix).
|
|
404
427
|
const bin = RECONCILE_ALLOWLISTED_BINS.glab;
|
|
@@ -414,15 +437,18 @@ export async function listExistingFindings({
|
|
|
414
437
|
|
|
415
438
|
const args = ['issue', 'list', '--label', label, '--output', 'json'];
|
|
416
439
|
|
|
417
|
-
// Optionally scope to a specific project (--repo flag)
|
|
418
|
-
|
|
419
|
-
|
|
440
|
+
// Optionally scope to a specific project (--repo flag). #872: explicit
|
|
441
|
+
// project always wins; otherwise auto-detect a --repo spec from the local
|
|
442
|
+
// git remotes (host-pinning).
|
|
443
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
444
|
+
if (effectiveProject !== undefined) {
|
|
445
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
420
446
|
return {
|
|
421
447
|
ok: false,
|
|
422
448
|
error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
|
|
423
449
|
};
|
|
424
450
|
}
|
|
425
|
-
args.push('--repo',
|
|
451
|
+
args.push('--repo', effectiveProject);
|
|
426
452
|
}
|
|
427
453
|
|
|
428
454
|
let stdout;
|
|
@@ -501,7 +527,7 @@ export async function listExistingFindings({
|
|
|
501
527
|
* Create a new finding as a GitLab issue.
|
|
502
528
|
*
|
|
503
529
|
* @param {object} opts
|
|
504
|
-
* @param {string} [opts.project] - GitLab project path (--repo)
|
|
530
|
+
* @param {string} [opts.project] - GitLab project path (--repo; #872: otherwise auto-detected)
|
|
505
531
|
* @param {string} opts.fingerprint - 16-hex fingerprint (appended as sentinel)
|
|
506
532
|
* @param {string} opts.title - issue title (no [Test] prefix added here — caller decides)
|
|
507
533
|
* @param {string} opts.body - issue description body; must not exceed 65536 bytes (#389)
|
|
@@ -509,6 +535,8 @@ export async function listExistingFindings({
|
|
|
509
535
|
* @param {boolean} [opts.dryRun=false] - if true, return command without spawning
|
|
510
536
|
* @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB, #389)
|
|
511
537
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
538
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
539
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
512
540
|
* @returns {Promise<
|
|
513
541
|
* {ok: true, action: 'create', iid?: number, command?: string[]}
|
|
514
542
|
* | {ok: false, error: {code: string, message: string}}
|
|
@@ -523,6 +551,8 @@ export async function createFinding({
|
|
|
523
551
|
dryRun = false,
|
|
524
552
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
525
553
|
execFile: execFileOpt,
|
|
554
|
+
repoRoot = process.cwd(),
|
|
555
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
526
556
|
}) {
|
|
527
557
|
// --- Input validation ---
|
|
528
558
|
if (typeof title !== 'string' || title.length === 0) {
|
|
@@ -562,14 +592,17 @@ export async function createFinding({
|
|
|
562
592
|
|
|
563
593
|
const args = ['issue', 'create', '--title', title, '--label', labels, '--description', body];
|
|
564
594
|
|
|
565
|
-
|
|
566
|
-
|
|
595
|
+
// #872: explicit project always wins; otherwise auto-detect a --repo spec
|
|
596
|
+
// from the local git remotes (host-pinning).
|
|
597
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
598
|
+
if (effectiveProject !== undefined) {
|
|
599
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
567
600
|
return {
|
|
568
601
|
ok: false,
|
|
569
602
|
error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
|
|
570
603
|
};
|
|
571
604
|
}
|
|
572
|
-
args.push('--repo',
|
|
605
|
+
args.push('--repo', effectiveProject);
|
|
573
606
|
}
|
|
574
607
|
|
|
575
608
|
if (dryRun) {
|
|
@@ -616,12 +649,14 @@ export async function createFinding({
|
|
|
616
649
|
* Add a comment to an existing finding issue.
|
|
617
650
|
*
|
|
618
651
|
* @param {object} opts
|
|
619
|
-
* @param {string} [opts.project] - GitLab project path (--repo)
|
|
652
|
+
* @param {string} [opts.project] - GitLab project path (--repo; #872: otherwise auto-detected)
|
|
620
653
|
* @param {number} opts.iid - issue IID to comment on
|
|
621
654
|
* @param {string} opts.comment - comment body; must not exceed 65536 bytes (#389)
|
|
622
655
|
* @param {boolean} [opts.dryRun=false] - if true, return command without spawning
|
|
623
656
|
* @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB, #389)
|
|
624
657
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
658
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
659
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
625
660
|
* @returns {Promise<
|
|
626
661
|
* {ok: true, action: 'comment', command?: string[]}
|
|
627
662
|
* | {ok: false, error: {code: string, message: string}}
|
|
@@ -634,6 +669,8 @@ export async function updateFinding({
|
|
|
634
669
|
dryRun = false,
|
|
635
670
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
636
671
|
execFile: execFileOpt,
|
|
672
|
+
repoRoot = process.cwd(),
|
|
673
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
637
674
|
}) {
|
|
638
675
|
// --- Input validation ---
|
|
639
676
|
if (!Number.isInteger(iid) || iid < 1) {
|
|
@@ -657,14 +694,17 @@ export async function updateFinding({
|
|
|
657
694
|
// iid is a number — safe to convert to string; no arg-boundary concerns
|
|
658
695
|
const args = ['issue', 'note', String(iid), '--message', comment];
|
|
659
696
|
|
|
660
|
-
|
|
661
|
-
|
|
697
|
+
// #872: explicit project always wins; otherwise auto-detect a --repo spec
|
|
698
|
+
// from the local git remotes (host-pinning).
|
|
699
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
700
|
+
if (effectiveProject !== undefined) {
|
|
701
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
662
702
|
return {
|
|
663
703
|
ok: false,
|
|
664
704
|
error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
|
|
665
705
|
};
|
|
666
706
|
}
|
|
667
|
-
args.push('--repo',
|
|
707
|
+
args.push('--repo', effectiveProject);
|
|
668
708
|
}
|
|
669
709
|
|
|
670
710
|
if (dryRun) {
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tests-src-ratio.mjs — THE canonical tests:src LOC measurement for TV-003
|
|
4
|
+
* (`.claude/rules/test-value.md` § TV-003 Budget Corridor).
|
|
5
|
+
*
|
|
6
|
+
* ## Why this file exists
|
|
7
|
+
*
|
|
8
|
+
* TV-003 named a ceiling but no recipe, so the recipe lived in prose — and prose
|
|
9
|
+
* re-derives differently for every person who measures. On 2026-07-30 SIX numbers
|
|
10
|
+
* for the one metric were in simultaneous circulation (1.7142 / 1.7360 / 1.74 /
|
|
11
|
+
* 1.7779 / 1.8032 / 1.8084), differing only in which files each measurer chose to
|
|
12
|
+
* count. A threshold steered by six numbers is steered blind. This module is the
|
|
13
|
+
* single answer: the recipe is code, the number is reproducible at a SHA, and the
|
|
14
|
+
* rule text points here instead of describing a seventh calculation.
|
|
15
|
+
*
|
|
16
|
+
* ## The recipe (a PARTITION, not two independent globs)
|
|
17
|
+
*
|
|
18
|
+
* Every tracked code file lands in EXACTLY ONE bucket — that property is the
|
|
19
|
+
* whole design. Two independently-authored globs are how the six numbers arose:
|
|
20
|
+
* each hand-picked src list (`scripts+hooks` vs `+skills` vs `everything`) both
|
|
21
|
+
* dropped files silently and invited the next measurer to pick differently.
|
|
22
|
+
* Here `src` is defined by NEGATION, so a new top-level directory joins the
|
|
23
|
+
* denominator the moment it is committed, with no rule edit and no re-derivation.
|
|
24
|
+
*
|
|
25
|
+
* universe `git ls-files`, filtered to CODE_EXTENSIONS
|
|
26
|
+
* numerator tracked code under `tests/`
|
|
27
|
+
* denominator every OTHER tracked code file
|
|
28
|
+
*
|
|
29
|
+
* ## The four questions, answered in code rather than left open
|
|
30
|
+
*
|
|
31
|
+
* Do `skills/**.mjs` count as src? YES — shipped product code, and no
|
|
32
|
+
* hand-picked list decides it; negation does.
|
|
33
|
+
* Do `.md` files count? NO — neither side. Documentation volume
|
|
34
|
+
* is a different budget (instruction-budget);
|
|
35
|
+
* mixing them makes the ratio movable by
|
|
36
|
+
* writing prose, which catches no bug.
|
|
37
|
+
* Only git-tracked? YES — reproducible at a SHA, and it is
|
|
38
|
+
* what keeps node_modules/, coverage/ and
|
|
39
|
+
* untracked scratch out without an ignore list.
|
|
40
|
+
* Do `tests/fixtures/` count as test LOC? YES when they are code (`.mjs`/`.js`/
|
|
41
|
+
* `.cjs`). A code fixture is maintained
|
|
42
|
+
* code that exists only to serve the suite —
|
|
43
|
+
* that is test-corpus cost. Non-code
|
|
44
|
+
* fixtures (`.json`, `.jsonl`, `.md`) are
|
|
45
|
+
* excluded by the same extension filter
|
|
46
|
+
* that governs the denominator.
|
|
47
|
+
* Blank lines and comments? COUNTED — see countPhysicalLines().
|
|
48
|
+
*
|
|
49
|
+
* ## What this is NOT
|
|
50
|
+
*
|
|
51
|
+
* Not a ratchet, and deliberately so. TV-003 is a CORRIDOR: exceeding the ceiling
|
|
52
|
+
* switches the consolidation rule on, it does not break the build. `--check` is a
|
|
53
|
+
* machine-readable answer to "is the consolidation wave required?", which is the
|
|
54
|
+
* only job TV-003 gives the ratio ("the ratio is merely the trigger that switches
|
|
55
|
+
* it on"). Wiring it as a blocking CI gate would manufacture standing deletion
|
|
56
|
+
* pressure with no nameable target per file — the precise thing TV-001 and TV-002
|
|
57
|
+
* forbid, and the documented reason the predecessor ceiling of 1.20 was abandoned.
|
|
58
|
+
*
|
|
59
|
+
* Usage:
|
|
60
|
+
* tests-src-ratio.mjs [<repo-root>] [--json] [--check] [--ceiling <n>] [--stdin]
|
|
61
|
+
*
|
|
62
|
+
* <repo-root> defaults to process.cwd()
|
|
63
|
+
* --json emit a single JSON object on stdout, nothing else
|
|
64
|
+
* --check exit 1 when the ratio exceeds the ceiling (consolidation
|
|
65
|
+
* wave required); exit 0 when inside the corridor
|
|
66
|
+
* --ceiling <n> override the TV-003 ceiling (default 1.60)
|
|
67
|
+
* --stdin take newline-separated paths from stdin instead of
|
|
68
|
+
* enumerating via `git ls-files` (test seam / staged-only mode)
|
|
69
|
+
*
|
|
70
|
+
* Exit codes:
|
|
71
|
+
* 0 — measurement completed (and, under --check, ratio is within the corridor)
|
|
72
|
+
* 1 — --check only: ratio exceeds the ceiling
|
|
73
|
+
* 2 — tool error (missing/unreadable root, bad argv)
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
77
|
+
import { join, isAbsolute, relative, resolve } from 'node:path';
|
|
78
|
+
import { execFileSync } from 'node:child_process';
|
|
79
|
+
import { fileURLToPath } from 'node:url';
|
|
80
|
+
|
|
81
|
+
import { writeStdoutLineSync } from './io.mjs';
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// The definition — the part that must never be re-derived by hand
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extensions that count as CODE on both sides of the ratio. Anything else is in
|
|
89
|
+
* neither bucket: `.md` is documentation, `.json`/`.jsonl`/`.yml` are data.
|
|
90
|
+
* Applied symmetrically to numerator and denominator by construction — a file
|
|
91
|
+
* type can never inflate one side while being invisible to the other.
|
|
92
|
+
*/
|
|
93
|
+
export const CODE_EXTENSIONS = Object.freeze(['.mjs', '.js', '.cjs']);
|
|
94
|
+
|
|
95
|
+
/** Path prefix that makes a tracked code file part of the TEST corpus. */
|
|
96
|
+
export const TEST_PREFIX = 'tests/';
|
|
97
|
+
|
|
98
|
+
/** The TV-003 ceiling. Exceeding it switches the consolidation rule on. */
|
|
99
|
+
export const DEFAULT_CEILING = 1.6;
|
|
100
|
+
|
|
101
|
+
/** Machine-readable schema tag for the --json envelope. */
|
|
102
|
+
export const SCHEMA = 'tests-src-ratio/1';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Which bucket a repo-relative path belongs to.
|
|
106
|
+
*
|
|
107
|
+
* `src` is defined by NEGATION — every tracked code file that is not under
|
|
108
|
+
* `tests/`. That is what makes the two buckets a partition rather than two
|
|
109
|
+
* globs that can overlap or leave a gap.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} relPath repo-relative path, `/`-separated
|
|
112
|
+
* @returns {'test'|'src'|null} null = outside the metric entirely
|
|
113
|
+
*/
|
|
114
|
+
export function classifyPath(relPath) {
|
|
115
|
+
const p = String(relPath).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
116
|
+
if (!CODE_EXTENSIONS.some((ext) => p.endsWith(ext))) return null;
|
|
117
|
+
return p === TEST_PREFIX.slice(0, -1) || p.startsWith(TEST_PREFIX) ? 'test' : 'src';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Physical line count — blank lines and comment lines INCLUDED.
|
|
122
|
+
*
|
|
123
|
+
* Two deliberate choices:
|
|
124
|
+
*
|
|
125
|
+
* 1. No comment stripping. Stripping would need a real JS parser (a `//` inside
|
|
126
|
+
* a string, a regex literal, or a template literal is not a comment), so the
|
|
127
|
+
* stripper itself becomes a bug surface in the measuring instrument. It also
|
|
128
|
+
* inverts the incentive: comments sit in the DENOMINATOR too, so a stripper
|
|
129
|
+
* would reward deleting explanatory comments from src to move the ratio.
|
|
130
|
+
*
|
|
131
|
+
* 2. EOF-newline-insensitive. `wc -l` counts newline BYTES, so a file whose last
|
|
132
|
+
* line has no trailing newline is undercounted by one — an off-by-one that
|
|
133
|
+
* varies with an invisible byte. Here a trailing empty segment is dropped, so
|
|
134
|
+
* "10 lines" means ten lines with or without the final newline.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} content
|
|
137
|
+
* @returns {number}
|
|
138
|
+
*/
|
|
139
|
+
export function countPhysicalLines(content) {
|
|
140
|
+
if (content === '') return 0;
|
|
141
|
+
const parts = content.split('\n');
|
|
142
|
+
if (parts[parts.length - 1] === '') parts.pop();
|
|
143
|
+
return parts.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Measure the ratio over an explicit file list.
|
|
148
|
+
*
|
|
149
|
+
* Enumeration is injected rather than performed here, which is what lets the
|
|
150
|
+
* test drive a controlled fixture without a git repository.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} opts
|
|
153
|
+
* @param {string[]} opts.files repo-relative paths
|
|
154
|
+
* @param {(relPath: string) => string|null} opts.readFile returns content, or null when unreadable
|
|
155
|
+
* @param {number} [opts.ceiling]
|
|
156
|
+
* @returns {{testFiles:number,testLoc:number,srcFiles:number,srcLoc:number,ratio:number|null,
|
|
157
|
+
* ceiling:number,withinCorridor:boolean,consolidationWaveRequired:boolean,skipped:number}}
|
|
158
|
+
*/
|
|
159
|
+
export function measure({ files, readFile, ceiling = DEFAULT_CEILING }) {
|
|
160
|
+
let testFiles = 0;
|
|
161
|
+
let testLoc = 0;
|
|
162
|
+
let srcFiles = 0;
|
|
163
|
+
let srcLoc = 0;
|
|
164
|
+
let skipped = 0;
|
|
165
|
+
|
|
166
|
+
for (const rel of files) {
|
|
167
|
+
const bucket = classifyPath(rel);
|
|
168
|
+
if (bucket === null) continue;
|
|
169
|
+
const content = readFile(rel);
|
|
170
|
+
if (content === null || content === undefined) {
|
|
171
|
+
skipped++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const lines = countPhysicalLines(content);
|
|
175
|
+
if (bucket === 'test') {
|
|
176
|
+
testFiles++;
|
|
177
|
+
testLoc += lines;
|
|
178
|
+
} else {
|
|
179
|
+
srcFiles++;
|
|
180
|
+
srcLoc += lines;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A repo with no src code has an undefined ratio, not an infinite one. Saying
|
|
185
|
+
// `null` keeps the consumer from reading Infinity as a corridor breach.
|
|
186
|
+
const ratio = srcLoc === 0 ? null : Number((testLoc / srcLoc).toFixed(4));
|
|
187
|
+
const withinCorridor = ratio === null ? true : ratio <= ceiling;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
testFiles,
|
|
191
|
+
testLoc,
|
|
192
|
+
srcFiles,
|
|
193
|
+
srcLoc,
|
|
194
|
+
ratio,
|
|
195
|
+
ceiling,
|
|
196
|
+
withinCorridor,
|
|
197
|
+
consolidationWaveRequired: !withinCorridor,
|
|
198
|
+
skipped,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The self-describing definition block shipped inside every --json envelope, so
|
|
204
|
+
* a consumer never has to guess which recipe produced the number.
|
|
205
|
+
* @param {'git ls-files'|'stdin'} source
|
|
206
|
+
*/
|
|
207
|
+
export function definitionOf(source) {
|
|
208
|
+
return {
|
|
209
|
+
source: source === 'stdin' ? 'stdin path list' : 'git ls-files (tracked files only)',
|
|
210
|
+
codeExtensions: [...CODE_EXTENSIONS],
|
|
211
|
+
numerator: `tracked code files under ${TEST_PREFIX}`,
|
|
212
|
+
denominator: `every OTHER tracked code file (src defined by negation, not by a directory list)`,
|
|
213
|
+
lineRule: 'physical lines; blank + comment lines counted; EOF-newline-insensitive',
|
|
214
|
+
excluded:
|
|
215
|
+
'non-code extensions (.md, .json, .jsonl, .yml) on BOTH sides; untracked files (node_modules/, coverage/, scratch)',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Enumeration
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** @param {string} root */
|
|
224
|
+
function trackedFiles(root) {
|
|
225
|
+
const out = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
226
|
+
return out
|
|
227
|
+
.split('\n')
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.map((rel) => rel.replace(/\\/g, '/'));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @param {string} root */
|
|
233
|
+
function stdinPaths(root) {
|
|
234
|
+
let raw;
|
|
235
|
+
try {
|
|
236
|
+
raw = readFileSync(0, 'utf8');
|
|
237
|
+
} catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
return raw
|
|
241
|
+
.split('\n')
|
|
242
|
+
.map((l) => l.trim())
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map((p) => (isAbsolute(p) ? relative(root, p) : p).replace(/\\/g, '/'));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Short HEAD SHA, or null outside a git repo — the PSA-006 "measured WHEN" anchor. */
|
|
248
|
+
function headRef(root) {
|
|
249
|
+
try {
|
|
250
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
251
|
+
cwd: root,
|
|
252
|
+
encoding: 'utf8',
|
|
253
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
254
|
+
}).trim();
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether any TRACKED CODE file differs from the index/HEAD.
|
|
262
|
+
*
|
|
263
|
+
* Load-bearing, not cosmetic. Enumeration reads the git INDEX but line counts
|
|
264
|
+
* read the WORKING TREE, so on a dirty tree the pair (`ref`, `ratio`) is a claim
|
|
265
|
+
* nobody can reproduce at that SHA. Observed live while this module was written:
|
|
266
|
+
* a sibling agent grew one tracked src file 543 → 683 lines mid-measurement and
|
|
267
|
+
* the "same" ratio moved across three consecutive runs. `ref` alone would have
|
|
268
|
+
* stamped all three with the identical SHA.
|
|
269
|
+
*
|
|
270
|
+
* PSA-006 requires a measurement to carry WHEN it was taken; a SHA that does not
|
|
271
|
+
* reproduce the number fails that requirement while looking like it satisfies it.
|
|
272
|
+
*
|
|
273
|
+
* @returns {boolean|null} null when git cannot answer (not a repo)
|
|
274
|
+
*/
|
|
275
|
+
function isDirty(root) {
|
|
276
|
+
try {
|
|
277
|
+
// --no-optional-locks is load-bearing, not tidiness: a plain `git status`
|
|
278
|
+
// opportunistically refreshes and therefore LOCKS .git/index, which races a
|
|
279
|
+
// parallel session's index write (PSA-007). This matters here specifically
|
|
280
|
+
// because .claude/rules/test-value.md now instructs agents to run this
|
|
281
|
+
// script, so it executes inside live sessions. Measured on git 2.50.1 with
|
|
282
|
+
// stale stat info: plain status rewrote .git/index, the flagged form did not.
|
|
283
|
+
// Same flag, same reason as hooks/post-bash-write-verify.mjs.
|
|
284
|
+
const out = execFileSync('git', ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'], {
|
|
285
|
+
cwd: root,
|
|
286
|
+
encoding: 'utf8',
|
|
287
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
288
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
289
|
+
});
|
|
290
|
+
return out
|
|
291
|
+
.split('\n')
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.some((l) => classifyPath(l.slice(3).trim().split(' -> ').pop() ?? '') !== null);
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// CLI
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
const USAGE =
|
|
304
|
+
'Usage: tests-src-ratio.mjs [<repo-root>] [--json] [--check] [--ceiling <n>] [--stdin]';
|
|
305
|
+
|
|
306
|
+
/** @param {string[]} argv */
|
|
307
|
+
/**
|
|
308
|
+
* Session-start Phase 4 banner probe.
|
|
309
|
+
*
|
|
310
|
+
* WHY THIS EXISTS: without it this module had zero consumers. TV-003 defines the
|
|
311
|
+
* ceiling as the trigger for a consolidation wave — and the trigger fired into a
|
|
312
|
+
* void, since the only references were two rule files asking a human to type the
|
|
313
|
+
* command. "Not a blocking gate" was conflated with "not wired at all"; the
|
|
314
|
+
* counter-example shipped in the same commit range, where `checkInstructionBudget`
|
|
315
|
+
* is equally non-blocking and does get a Phase 4 banner. This closes that asymmetry
|
|
316
|
+
* WITHOUT making the ratio a build gate — the arguments against a bidirectional
|
|
317
|
+
* ratchet in `.claude/rules/test-value.md` § TV-003 stand unchanged.
|
|
318
|
+
*
|
|
319
|
+
* Contract matches the sibling probes (`checkInstructionBudget`, `checkCiStatus`,
|
|
320
|
+
* `checkMocStaleness`): returns `null` for "nothing to say", or a single
|
|
321
|
+
* `{ severity, message }` record. Never throws — any failure degrades to silence,
|
|
322
|
+
* because a measurement problem must not block a session start.
|
|
323
|
+
*
|
|
324
|
+
* @param {{ repoRoot?: string, ceiling?: number }} [opts]
|
|
325
|
+
* @returns {{ severity: 'warn', message: string, ratio: number, ceiling: number } | null}
|
|
326
|
+
*/
|
|
327
|
+
export function checkTestsSrcRatio({ repoRoot, ceiling = DEFAULT_CEILING } = {}) {
|
|
328
|
+
try {
|
|
329
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
330
|
+
const root = resolve(repoRoot);
|
|
331
|
+
if (!existsSync(root)) return null;
|
|
332
|
+
|
|
333
|
+
const files = trackedFiles(root);
|
|
334
|
+
const readFile = (rel) => {
|
|
335
|
+
const abs = join(root, rel);
|
|
336
|
+
try {
|
|
337
|
+
if (!statSync(abs).isFile()) return null;
|
|
338
|
+
return readFileSync(abs, 'utf8');
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const result = measure({ files, readFile, ceiling });
|
|
345
|
+
if (result.ratio === null || result.withinCorridor) return null;
|
|
346
|
+
|
|
347
|
+
const dirty = isDirty(root);
|
|
348
|
+
return {
|
|
349
|
+
severity: 'warn',
|
|
350
|
+
ratio: result.ratio,
|
|
351
|
+
ceiling: result.ceiling,
|
|
352
|
+
message:
|
|
353
|
+
`⚠ tests:src ${result.ratio.toFixed(4)} > ceiling ${result.ceiling} — ` +
|
|
354
|
+
`TV-003 consolidation wave is ON: no new test lands without removing a redundant one ` +
|
|
355
|
+
`(${result.testLoc} test LOC / ${result.srcLoc} src LOC across ` +
|
|
356
|
+
`${result.testFiles} + ${result.srcFiles} files${dirty ? ', dirty tree' : ''}). ` +
|
|
357
|
+
`Detail: node scripts/lib/tests-src-ratio.mjs --json`,
|
|
358
|
+
};
|
|
359
|
+
} catch {
|
|
360
|
+
return null; // never block a session start on a measurement failure
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function parseArgs(argv) {
|
|
365
|
+
const KNOWN = new Set(['--json', '--check', '--stdin', '--ceiling', '--help']);
|
|
366
|
+
const positionals = [];
|
|
367
|
+
let json = false;
|
|
368
|
+
let check = false;
|
|
369
|
+
let stdin = false;
|
|
370
|
+
let help = false;
|
|
371
|
+
let ceiling = DEFAULT_CEILING;
|
|
372
|
+
|
|
373
|
+
for (let i = 0; i < argv.length; i++) {
|
|
374
|
+
const a = argv[i];
|
|
375
|
+
if (!a.startsWith('--')) {
|
|
376
|
+
positionals.push(a);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (!KNOWN.has(a)) return { error: `Unknown flag: ${a}` };
|
|
380
|
+
if (a === '--json') json = true;
|
|
381
|
+
else if (a === '--check') check = true;
|
|
382
|
+
else if (a === '--stdin') stdin = true;
|
|
383
|
+
else if (a === '--help') help = true;
|
|
384
|
+
else if (a === '--ceiling') {
|
|
385
|
+
const v = Number(argv[++i]);
|
|
386
|
+
if (!Number.isFinite(v) || v <= 0) return { error: '--ceiling requires a positive number' };
|
|
387
|
+
ceiling = v;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (positionals.length > 1) return { error: 'at most one positional <repo-root> is accepted' };
|
|
391
|
+
return { json, check, stdin, help, ceiling, root: positionals[0] };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function main() {
|
|
395
|
+
const args = parseArgs(process.argv.slice(2));
|
|
396
|
+
if (args.error) {
|
|
397
|
+
console.error(`Error: ${args.error}`);
|
|
398
|
+
console.error(USAGE);
|
|
399
|
+
process.exit(2);
|
|
400
|
+
}
|
|
401
|
+
if (args.help) {
|
|
402
|
+
console.log(USAGE);
|
|
403
|
+
console.log('');
|
|
404
|
+
console.log('The canonical tests:src LOC measurement for TV-003 (test-value.md).');
|
|
405
|
+
console.log(' numerator tracked code under tests/');
|
|
406
|
+
console.log(' denominator every other tracked code file (src by negation)');
|
|
407
|
+
console.log(` code exts ${CODE_EXTENSIONS.join(' ')} (.md / .json never counted)`);
|
|
408
|
+
console.log(' lines physical; blanks + comments counted; EOF-newline-insensitive');
|
|
409
|
+
console.log('');
|
|
410
|
+
console.log(' --json machine-readable envelope on stdout');
|
|
411
|
+
console.log(' --check exit 1 when the ratio exceeds the ceiling');
|
|
412
|
+
console.log(` --ceiling <n> override the TV-003 ceiling (default ${DEFAULT_CEILING})`);
|
|
413
|
+
console.log(' --stdin read newline-separated paths instead of git ls-files');
|
|
414
|
+
console.log('');
|
|
415
|
+
console.log('Exit: 0 ok / within corridor · 1 (--check) ceiling exceeded · 2 tool error');
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const root = resolve(args.root ?? process.cwd());
|
|
420
|
+
if (!existsSync(root)) {
|
|
421
|
+
console.error(`Error: repo root does not exist: ${root}`);
|
|
422
|
+
process.exit(2);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let files;
|
|
426
|
+
try {
|
|
427
|
+
files = args.stdin ? stdinPaths(root) : trackedFiles(root);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
console.error(`Error: could not enumerate files under ${root}: ${err?.message ?? err}`);
|
|
430
|
+
process.exit(2);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const readFile = (rel) => {
|
|
434
|
+
const abs = join(root, rel);
|
|
435
|
+
try {
|
|
436
|
+
if (!statSync(abs).isFile()) return null;
|
|
437
|
+
return readFileSync(abs, 'utf8');
|
|
438
|
+
} catch {
|
|
439
|
+
return null; // deleted/unreadable — counted as skipped, never as 0 lines
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const result = measure({ files, readFile, ceiling: args.ceiling });
|
|
444
|
+
const dirty = args.stdin ? null : isDirty(root);
|
|
445
|
+
const envelope = {
|
|
446
|
+
schema: SCHEMA,
|
|
447
|
+
measuredAt: new Date().toISOString(),
|
|
448
|
+
root,
|
|
449
|
+
ref: headRef(root),
|
|
450
|
+
// `ref` is only a reproducible anchor when `dirty` is false — see isDirty().
|
|
451
|
+
dirty,
|
|
452
|
+
definition: definitionOf(args.stdin ? 'stdin' : 'git ls-files'),
|
|
453
|
+
...result,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
if (args.json) {
|
|
457
|
+
// writeStdoutLineSync, NOT console.log: stdout is async on a pipe on macOS,
|
|
458
|
+
// so anything past the ~64 KiB kernel buffer is discarded by process.exit().
|
|
459
|
+
// This envelope is small today, but --check exits explicitly below and the
|
|
460
|
+
// fail-open class is not worth re-litigating per payload size.
|
|
461
|
+
writeStdoutLineSync(JSON.stringify(envelope, null, 2));
|
|
462
|
+
} else {
|
|
463
|
+
const r = result.ratio === null ? 'n/a (no src code)' : result.ratio.toFixed(4);
|
|
464
|
+
const verdict = result.withinCorridor
|
|
465
|
+
? 'within corridor'
|
|
466
|
+
: `ABOVE ceiling ${result.ceiling} — TV-003 consolidation wave required`;
|
|
467
|
+
const at = envelope.ref ? ` @ ${envelope.ref}${dirty ? '+dirty' : ''}` : '';
|
|
468
|
+
writeStdoutLineSync(
|
|
469
|
+
`tests:src = ${r} (${result.testLoc} test LOC / ${result.srcLoc} src LOC` +
|
|
470
|
+
`; ${result.testFiles} test + ${result.srcFiles} src files${at}) — ${verdict}` +
|
|
471
|
+
(dirty ? '\n NOTE: working tree is dirty — this number is NOT reproducible at that SHA' : ''),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
process.exit(args.check && !result.withinCorridor ? 1 : 0);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const isMain =
|
|
479
|
+
typeof process !== 'undefined' &&
|
|
480
|
+
process.argv[1] !== null &&
|
|
481
|
+
process.argv[1] !== undefined &&
|
|
482
|
+
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
483
|
+
|
|
484
|
+
if (isMain) main();
|