@juzser/blacksmith 0.1.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/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
/** The taxonomy class raised when a task reports done with work still in the working tree. */
|
|
3
|
+
export const UNCOMMITTED_WORK_CODE = 'contract.uncommitted-work';
|
|
4
|
+
const PORCELAIN_STATUS_WIDTH = 3; // "XY " — two status chars plus the separator
|
|
5
|
+
function git(cwd, args) {
|
|
6
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
7
|
+
.toString()
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
function isGitWorktree(worktreeDir) {
|
|
11
|
+
try {
|
|
12
|
+
git(worktreeDir, ['rev-parse', '--is-inside-work-tree']);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function headSha(worktreeDir) {
|
|
20
|
+
try {
|
|
21
|
+
return git(worktreeDir, ['rev-parse', 'HEAD']);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null; // unborn branch — nothing has ever been committed here
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function currentBranch(worktreeDir) {
|
|
28
|
+
try {
|
|
29
|
+
return git(worktreeDir, ['branch', '--show-current']) || null; // empty when detached
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function resolveRef(worktreeDir, ref) {
|
|
36
|
+
try {
|
|
37
|
+
return git(worktreeDir, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Every path git currently reports as dirty, staged or untracked, `-z`-parsed
|
|
45
|
+
* so spaces and newlines in filenames survive. Renames emit destination then
|
|
46
|
+
* source; both count, since a rename left two paths uncommitted.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately without `--ignored`: build output and node_modules are not work
|
|
49
|
+
* the coder forgot to commit, and a gate that blocked on `dist/` would be
|
|
50
|
+
* unusable in exactly the repos that need it most.
|
|
51
|
+
*
|
|
52
|
+
* `--untracked-files=all` for the two reasons its siblings in `claims.ts` and
|
|
53
|
+
* `immutability.ts` already pass it, and one more that only bites here (D-178):
|
|
54
|
+
* the default `normal` collapses a whole new directory into a single `src/`
|
|
55
|
+
* entry, so a certificate promising to name every dirty path named a directory
|
|
56
|
+
* instead; and the mode is *configurable*, so a repo, global, or system
|
|
57
|
+
* `status.showUntrackedFiles = no` made untracked work invisible to the read
|
|
58
|
+
* and this gate certified a worktree with uncommitted new files. Passing it
|
|
59
|
+
* explicitly makes the setting unable to weaken the check — which is the whole
|
|
60
|
+
* point of D-30. Untracked is not an edge case here: a task's first write is
|
|
61
|
+
* usually a new file.
|
|
62
|
+
*/
|
|
63
|
+
function dirtyPaths(worktreeDir) {
|
|
64
|
+
const output = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
|
|
65
|
+
cwd: worktreeDir,
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
});
|
|
68
|
+
const fields = output.split('\0');
|
|
69
|
+
const paths = [];
|
|
70
|
+
for (let i = 0; i < fields.length; i++) {
|
|
71
|
+
const field = fields[i];
|
|
72
|
+
if (field.length <= PORCELAIN_STATUS_WIDTH)
|
|
73
|
+
continue;
|
|
74
|
+
const status = field.slice(0, 2);
|
|
75
|
+
paths.push(field.slice(PORCELAIN_STATUS_WIDTH));
|
|
76
|
+
if (status.startsWith('R') || status.startsWith('C')) {
|
|
77
|
+
const source = fields[i + 1];
|
|
78
|
+
if (source)
|
|
79
|
+
paths.push(source);
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return paths;
|
|
84
|
+
}
|
|
85
|
+
function blocked(reason, rest) {
|
|
86
|
+
return { certified: false, reason, ...rest };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Certify that a task worktree holds a commit worth merging (P9-8 / D-30).
|
|
90
|
+
*
|
|
91
|
+
* The gate scores a worktree; the merge queue merges a *branch*. When a task
|
|
92
|
+
* leaves its work staged-but-uncommitted, those are two different things: every
|
|
93
|
+
* check goes green against the working tree while the merge is a no-op that
|
|
94
|
+
* still reports success. This closes that gap from the cheap end — no checkout,
|
|
95
|
+
* two git reads — by refusing to score a worktree that is dirty, and refusing
|
|
96
|
+
* one whose branch head still equals the integration base it was cut from.
|
|
97
|
+
*
|
|
98
|
+
* `baseRef` is optional: an ad-hoc `gate run` against a repo with no
|
|
99
|
+
* integration branch is not forced to invent one, and says `commitsAhead: null`
|
|
100
|
+
* rather than pretending that half ran.
|
|
101
|
+
*/
|
|
102
|
+
export function certifyCommit(worktreeDir, opts = {}) {
|
|
103
|
+
const baseRef = opts.baseRef ?? null;
|
|
104
|
+
const empty = { head: null, branch: null, dirty: [], baseRef, baseSha: null, commitsAhead: null };
|
|
105
|
+
if (!isGitWorktree(worktreeDir))
|
|
106
|
+
return blocked('not-a-git-worktree', empty);
|
|
107
|
+
const branch = currentBranch(worktreeDir);
|
|
108
|
+
const dirty = dirtyPaths(worktreeDir);
|
|
109
|
+
const head = headSha(worktreeDir);
|
|
110
|
+
// Dirt first: when a worktree is both dirty and unadvanced, the uncommitted
|
|
111
|
+
// work is the cause and "commit it" is the whole fix.
|
|
112
|
+
if (dirty.length > 0) {
|
|
113
|
+
return blocked('uncommitted-work', {
|
|
114
|
+
head,
|
|
115
|
+
branch,
|
|
116
|
+
dirty,
|
|
117
|
+
baseRef,
|
|
118
|
+
baseSha: null,
|
|
119
|
+
commitsAhead: null,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (head === null)
|
|
123
|
+
return blocked('unborn-branch', { ...empty, branch });
|
|
124
|
+
if (baseRef === null) {
|
|
125
|
+
return {
|
|
126
|
+
certified: true,
|
|
127
|
+
reason: null,
|
|
128
|
+
head,
|
|
129
|
+
branch,
|
|
130
|
+
dirty,
|
|
131
|
+
baseRef,
|
|
132
|
+
baseSha: null,
|
|
133
|
+
commitsAhead: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const baseSha = resolveRef(worktreeDir, baseRef);
|
|
137
|
+
if (baseSha === null) {
|
|
138
|
+
return blocked('unknown-base', {
|
|
139
|
+
head,
|
|
140
|
+
branch,
|
|
141
|
+
dirty,
|
|
142
|
+
baseRef,
|
|
143
|
+
baseSha: null,
|
|
144
|
+
commitsAhead: null,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const commitsAhead = Number(git(worktreeDir, ['rev-list', '--count', `${baseSha}..HEAD`]));
|
|
148
|
+
if (commitsAhead === 0) {
|
|
149
|
+
return blocked('branch-not-advanced', { head, branch, dirty, baseRef, baseSha, commitsAhead });
|
|
150
|
+
}
|
|
151
|
+
return { certified: true, reason: null, head, branch, dirty, baseRef, baseSha, commitsAhead };
|
|
152
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SmithError } from './errors.js';
|
|
4
|
+
export class CoverageError extends SmithError {
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Where the `json-summary` reporter writes, relative to the worktree:
|
|
8
|
+
* vitest's default `coverage.reportsDirectory` is `./coverage`, and the
|
|
9
|
+
* reporter's filename is fixed.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_SUMMARY_PATH = 'coverage/coverage-summary.json';
|
|
12
|
+
const METRIC_KEYS = ['lines', 'statements', 'functions', 'branches'];
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function parseMetric(value, where) {
|
|
17
|
+
if (!isRecord(value)) {
|
|
18
|
+
throw new CoverageError('coverage.malformed-summary', `${where} is not an object`);
|
|
19
|
+
}
|
|
20
|
+
const nums = {};
|
|
21
|
+
for (const key of ['total', 'covered', 'skipped', 'pct']) {
|
|
22
|
+
const n = value[key];
|
|
23
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
24
|
+
throw new CoverageError('coverage.malformed-summary', `${where}.${key} is not a finite number`);
|
|
25
|
+
}
|
|
26
|
+
nums[key] = n;
|
|
27
|
+
}
|
|
28
|
+
return nums;
|
|
29
|
+
}
|
|
30
|
+
function parseFileCoverage(value, where) {
|
|
31
|
+
if (!isRecord(value)) {
|
|
32
|
+
throw new CoverageError('coverage.malformed-summary', `${where} is not an object`);
|
|
33
|
+
}
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const key of METRIC_KEYS) {
|
|
36
|
+
// Every metric is required. Defaulting a missing one to zero would invent
|
|
37
|
+
// a number, and defaulting it to 100 would invent a pass; both are worse
|
|
38
|
+
// than refusing to read the file.
|
|
39
|
+
out[key] = parseMetric(value[key], `${where}.${key}`);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Turn one file key into a worktree-relative POSIX path. The reporter emits
|
|
45
|
+
* absolute paths; a key that resolves outside the worktree is left exactly as
|
|
46
|
+
* it came, because a `../../..` path would be a worse label than the truth.
|
|
47
|
+
*/
|
|
48
|
+
function relativizeKey(key, worktreeDir) {
|
|
49
|
+
if (!path.isAbsolute(key))
|
|
50
|
+
return key.split(path.sep).join('/');
|
|
51
|
+
const rel = path.relative(worktreeDir, key);
|
|
52
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
53
|
+
return key;
|
|
54
|
+
return rel.split(path.sep).join('/');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse a `coverage-summary.json` document. Strict on shape: a summary that
|
|
58
|
+
* cannot be read must raise, never come back as an empty-but-valid one — an
|
|
59
|
+
* empty summary reads as "nothing was instrumented", which is a claim, and the
|
|
60
|
+
* wrong one.
|
|
61
|
+
*/
|
|
62
|
+
export function parseCoverageSummary(raw, worktreeDir) {
|
|
63
|
+
if (!isRecord(raw)) {
|
|
64
|
+
throw new CoverageError('coverage.malformed-summary', 'coverage summary is not an object');
|
|
65
|
+
}
|
|
66
|
+
if (!('total' in raw)) {
|
|
67
|
+
throw new CoverageError('coverage.malformed-summary', 'coverage summary has no "total" key — it is not a json-summary report');
|
|
68
|
+
}
|
|
69
|
+
const total = parseFileCoverage(raw.total, 'total');
|
|
70
|
+
const files = {};
|
|
71
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
72
|
+
if (key === 'total')
|
|
73
|
+
continue;
|
|
74
|
+
files[relativizeKey(key, worktreeDir)] = parseFileCoverage(value, key);
|
|
75
|
+
}
|
|
76
|
+
return { total, files };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read the summary a coverage run left behind. `null` means the file is not
|
|
80
|
+
* there; anything else that goes wrong throws, because "absent" and
|
|
81
|
+
* "unreadable" are different facts and only one of them is fixed by adding a
|
|
82
|
+
* reporter.
|
|
83
|
+
*/
|
|
84
|
+
export async function readCoverageSummary(worktreeDir, summaryPath = DEFAULT_SUMMARY_PATH) {
|
|
85
|
+
const abs = path.resolve(worktreeDir, summaryPath);
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = await readFile(abs, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.code === 'ENOENT')
|
|
92
|
+
return null;
|
|
93
|
+
throw new CoverageError('coverage.unreadable-summary', `could not read ${summaryPath}: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.parse(text);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new CoverageError('coverage.malformed-summary', `${summaryPath} is not valid JSON: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
return parseCoverageSummary(raw, worktreeDir);
|
|
103
|
+
}
|
|
104
|
+
const GLOB_CHARS = /[*?[\]{}!()]/;
|
|
105
|
+
/** A claim that names one file, not a region. */
|
|
106
|
+
function isLiteralClaim(claim) {
|
|
107
|
+
return !GLOB_CHARS.test(claim);
|
|
108
|
+
}
|
|
109
|
+
function dirOf(p) {
|
|
110
|
+
const i = p.lastIndexOf('/');
|
|
111
|
+
return i === -1 ? '' : p.slice(0, i);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Answer, for each file a task's criteria actually name, whether the coverage
|
|
115
|
+
* run produced a number for it. Glob claims are skipped: `src/**` names a
|
|
116
|
+
* region, and a region has no single number to report — a per-file criterion
|
|
117
|
+
* has to name a per-file path.
|
|
118
|
+
*/
|
|
119
|
+
export function coverageSubjects(summary, claims) {
|
|
120
|
+
const instrumentedDirs = new Set(Object.keys(summary.files).map(dirOf));
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const subjects = [];
|
|
123
|
+
for (const claim of claims) {
|
|
124
|
+
if (!isLiteralClaim(claim) || seen.has(claim))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(claim);
|
|
127
|
+
const coverage = summary.files[claim];
|
|
128
|
+
if (coverage !== undefined) {
|
|
129
|
+
subjects.push({
|
|
130
|
+
path: claim,
|
|
131
|
+
status: 'measured',
|
|
132
|
+
coverage,
|
|
133
|
+
detail: `${claim}: ${coverage.lines.pct}% lines, ${coverage.statements.pct}% statements, ${coverage.functions.pct}% functions, ${coverage.branches.pct}% branches.`,
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (instrumentedDirs.has(dirOf(claim))) {
|
|
138
|
+
subjects.push({
|
|
139
|
+
path: claim,
|
|
140
|
+
status: 'unmeasured',
|
|
141
|
+
coverage: null,
|
|
142
|
+
detail: `${claim} has no row in the coverage summary, yet other files in ${dirOf(claim) || '.'} do — the run did not measure the file the criterion names.`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
subjects.push({
|
|
147
|
+
path: claim,
|
|
148
|
+
status: 'not-instrumented',
|
|
149
|
+
coverage: null,
|
|
150
|
+
detail: `${claim} is outside the coverage include glob — nothing in ${dirOf(claim) || '.'} was instrumented.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return subjects;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The record a gate attaches instead of a scraped table: the per-file numbers
|
|
157
|
+
* as the reporter wrote them, plus an explicit verdict on whether they cover
|
|
158
|
+
* the files this task's criteria are about.
|
|
159
|
+
*/
|
|
160
|
+
export async function collectCoverageEvidence(input) {
|
|
161
|
+
const summaryPath = input.summaryPath ?? DEFAULT_SUMMARY_PATH;
|
|
162
|
+
const summary = await readCoverageSummary(input.worktreeDir, summaryPath);
|
|
163
|
+
if (summary === null) {
|
|
164
|
+
return {
|
|
165
|
+
summaryPath,
|
|
166
|
+
present: false,
|
|
167
|
+
total: null,
|
|
168
|
+
filesMeasured: 0,
|
|
169
|
+
subjects: [],
|
|
170
|
+
complete: false,
|
|
171
|
+
detail: `no ${summaryPath} after the coverage check — add "json-summary" to coverage.reporter in vitest.config.ts, because the text table hides every file at 100%.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const subjects = coverageSubjects(summary, input.claims);
|
|
175
|
+
const missing = subjects.filter((s) => s.status === 'unmeasured');
|
|
176
|
+
const filesMeasured = Object.keys(summary.files).length;
|
|
177
|
+
return {
|
|
178
|
+
summaryPath,
|
|
179
|
+
present: true,
|
|
180
|
+
total: summary.total,
|
|
181
|
+
filesMeasured,
|
|
182
|
+
subjects,
|
|
183
|
+
complete: missing.length === 0,
|
|
184
|
+
detail: missing.length === 0
|
|
185
|
+
? `${filesMeasured} files measured; ${subjects.filter((s) => s.status === 'measured').length} of ${subjects.length} named files have a per-file number.`
|
|
186
|
+
: `no per-file number for ${missing.map((s) => s.path).join(', ')} — the criterion names a file the coverage run did not measure.`,
|
|
187
|
+
};
|
|
188
|
+
}
|