@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,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running every test on every merge is honest and slow. This narrows it.
|
|
3
|
+
*
|
|
4
|
+
* The serial merge queue rebases each task onto the integration branch and
|
|
5
|
+
* runs the operator's whole test command before merging. That is correct and
|
|
6
|
+
* it is quadratic: N tasks, each paying for the full cumulative suite. The
|
|
7
|
+
* graph `symbols.ts` already builds answers a cheaper question — which tests
|
|
8
|
+
* can even reach this change — by walking `dependents` (imported file -> its
|
|
9
|
+
* importers) out from the changed files until the frontier stops growing.
|
|
10
|
+
*
|
|
11
|
+
* The whole value of a test gate is that it fails when the code is wrong, so
|
|
12
|
+
* every ambiguity here resolves to running everything:
|
|
13
|
+
*
|
|
14
|
+
* - a changed file the scanner cannot read, or has never seen, or that is not
|
|
15
|
+
* source at all (a `.yml`, a lockfile, a fixture) can affect anything;
|
|
16
|
+
* - a change no test reaches is more likely a stale graph than a change with
|
|
17
|
+
* no coverage, and guessing wrong means merging untested code;
|
|
18
|
+
* - a module with a computed `import()` or an import that never resolved is
|
|
19
|
+
* treated as reached by every change, because the scanner cannot prove it
|
|
20
|
+
* is not. That keeps one opaque module from collapsing the whole selection
|
|
21
|
+
* to `full`, while still never dropping the test that module lives in.
|
|
22
|
+
*
|
|
23
|
+
* Two things this deliberately does NOT do. It never narrows a typecheck —
|
|
24
|
+
* `tsc -p` is a whole-program question and a subset of files is a different,
|
|
25
|
+
* weaker question. And it never invents the command: the operator supplies a
|
|
26
|
+
* template with a `{files}` placeholder, and a template without one is an
|
|
27
|
+
* error rather than a silent full run reported as a selective one.
|
|
28
|
+
*/
|
|
29
|
+
import { extname } from 'node:path';
|
|
30
|
+
import { SmithError } from './errors.js';
|
|
31
|
+
import { DEFAULT_SOURCE_EXTENSIONS } from './symbols.js';
|
|
32
|
+
export class TestSelectError extends SmithError {
|
|
33
|
+
}
|
|
34
|
+
/** `foo.test.ts`, `foo.spec.tsx`, `foo.test.mjs` — the conventional shapes. */
|
|
35
|
+
const DEFAULT_TEST_PATTERN = /(^|\/)[^/]+\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
36
|
+
function isTest(file, options) {
|
|
37
|
+
return options.isTestFile ? options.isTestFile(file) : DEFAULT_TEST_PATTERN.test(file);
|
|
38
|
+
}
|
|
39
|
+
/** Modules the scanner admits it cannot fully see through. */
|
|
40
|
+
function alwaysReached(graph) {
|
|
41
|
+
const opaque = new Set();
|
|
42
|
+
for (const [file, facts] of graph.modules) {
|
|
43
|
+
if (facts.opaqueImports.length > 0)
|
|
44
|
+
opaque.add(file);
|
|
45
|
+
}
|
|
46
|
+
for (const { from } of graph.unresolved)
|
|
47
|
+
opaque.add(from);
|
|
48
|
+
return opaque;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Which tests can reach `changedFiles`, or `full` with the reason it gave up.
|
|
52
|
+
* Pure: the graph and the change set come from the caller.
|
|
53
|
+
*/
|
|
54
|
+
export function selectTests(graph, changedFiles, options = {}) {
|
|
55
|
+
const allTests = [...graph.modules.keys()].filter((file) => isTest(file, options)).sort();
|
|
56
|
+
const full = (reason) => ({
|
|
57
|
+
status: 'full',
|
|
58
|
+
tests: allTests,
|
|
59
|
+
allTests,
|
|
60
|
+
reasons: [reason],
|
|
61
|
+
});
|
|
62
|
+
if (allTests.length === 0)
|
|
63
|
+
return full('the symbol graph knows no test files');
|
|
64
|
+
if (changedFiles.length === 0)
|
|
65
|
+
return full('no changed files were reported');
|
|
66
|
+
const sourceExtensions = new Set(DEFAULT_SOURCE_EXTENSIONS);
|
|
67
|
+
const unanalyzed = new Set(graph.unanalyzed);
|
|
68
|
+
for (const file of changedFiles) {
|
|
69
|
+
if (!sourceExtensions.has(extname(file))) {
|
|
70
|
+
return full(`${file} is not an analyzable source file, so it could affect any test`);
|
|
71
|
+
}
|
|
72
|
+
if (unanalyzed.has(file)) {
|
|
73
|
+
return full(`${file} is a file the scanner refused to read`);
|
|
74
|
+
}
|
|
75
|
+
if (!graph.modules.has(file)) {
|
|
76
|
+
return full(`${file} is not in the symbol graph`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const reasons = [];
|
|
80
|
+
const opaque = alwaysReached(graph);
|
|
81
|
+
if (opaque.size > 0) {
|
|
82
|
+
reasons.push(`${opaque.size} module(s) with a computed import or an unresolved specifier are treated as reached`);
|
|
83
|
+
}
|
|
84
|
+
const reached = new Set();
|
|
85
|
+
const frontier = [...changedFiles, ...opaque];
|
|
86
|
+
while (frontier.length > 0) {
|
|
87
|
+
const file = frontier.pop();
|
|
88
|
+
if (file === undefined || reached.has(file))
|
|
89
|
+
continue;
|
|
90
|
+
reached.add(file);
|
|
91
|
+
for (const edge of graph.dependents.get(file) ?? []) {
|
|
92
|
+
if (!reached.has(edge.from))
|
|
93
|
+
frontier.push(edge.from);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const tests = [...reached].filter((file) => isTest(file, options)).sort();
|
|
97
|
+
if (tests.length === 0) {
|
|
98
|
+
return full('no test reaches the change, which is more likely a stale graph than a gap');
|
|
99
|
+
}
|
|
100
|
+
if (tests.length >= allTests.length) {
|
|
101
|
+
return full('every test is reachable from the change');
|
|
102
|
+
}
|
|
103
|
+
reasons.unshift(`${tests.length} of ${allTests.length} tests can reach the change`);
|
|
104
|
+
return { status: 'selected', tests, allTests, reasons };
|
|
105
|
+
}
|
|
106
|
+
/** POSIX single-quoting: the only string a shell will not reinterpret. */
|
|
107
|
+
function shellQuote(value) {
|
|
108
|
+
return `'${value.replaceAll("'", String.raw `'\''`)}'`;
|
|
109
|
+
}
|
|
110
|
+
const FILES_PLACEHOLDER = '{files}';
|
|
111
|
+
/**
|
|
112
|
+
* Reject a template with no placeholder. Called once at the CLI boundary so an
|
|
113
|
+
* unusable template fails before any git runs, and again at render time: that
|
|
114
|
+
* would run the full suite while the report said `selected`, which is the one
|
|
115
|
+
* failure mode this module must not have.
|
|
116
|
+
*/
|
|
117
|
+
export function assertSelectableTestCmd(template) {
|
|
118
|
+
if (!template.includes(FILES_PLACEHOLDER)) {
|
|
119
|
+
throw new TestSelectError('test-select.no-files-placeholder', `select test command has no ${FILES_PLACEHOLDER} placeholder: ${template}`, { template });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Substitute a selection into the operator's template. */
|
|
123
|
+
export function renderSelectedTestCmd(template, tests) {
|
|
124
|
+
assertSelectableTestCmd(template);
|
|
125
|
+
if (tests.length === 0) {
|
|
126
|
+
throw new TestSelectError('test-select.empty-selection', 'refusing to render a test command for an empty selection', { template });
|
|
127
|
+
}
|
|
128
|
+
return template.replaceAll(FILES_PLACEHOLDER, tests.map(shellQuote).join(' '));
|
|
129
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { readDispatchRecords } from './dispatchAudit.js';
|
|
2
|
+
import { eventTaskId } from './events.js';
|
|
3
|
+
import { taskIdsMatch } from './taskId.js';
|
|
4
|
+
/**
|
|
5
|
+
* The event this audit is anchored to. `testgate-result` is emitted once per
|
|
6
|
+
* gate run (gate.ts), and it is the moment the question becomes answerable:
|
|
7
|
+
* before it, a task whose tester has not been dispatched yet is merely early.
|
|
8
|
+
*/
|
|
9
|
+
const TEST_GATE_EVENT = 'testgate-result';
|
|
10
|
+
/**
|
|
11
|
+
* How a dispatched agent's turn is recorded as over, and which payload field
|
|
12
|
+
* names the role that ended it. Falling back to the record's `actor` is not a
|
|
13
|
+
* nicety: the older half of the log names the role there and nowhere else,
|
|
14
|
+
* and reading only the payload would report those runs as testers that never
|
|
15
|
+
* reported — an `unverifiable` about evidence the log is holding.
|
|
16
|
+
*/
|
|
17
|
+
const TERMINAL_ROLE_EVENTS = {
|
|
18
|
+
'task-result-recorded': 'agent',
|
|
19
|
+
'error-logged': 'agent',
|
|
20
|
+
'judge-reported': 'agent_role',
|
|
21
|
+
'judge-verdict': 'agent',
|
|
22
|
+
};
|
|
23
|
+
function payloadString(payload, key) {
|
|
24
|
+
const value = payload[key];
|
|
25
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether two task scopes can answer for each other. dispatchAudit.ts's rule,
|
|
29
|
+
* for its reasons: a record with no task id answers for anything (pre-task-id
|
|
30
|
+
* history is on disk and unrewritable), and "differ" is taskIdsMatch's
|
|
31
|
+
* question, not `===`'s, because the log writes one task both qualified and
|
|
32
|
+
* bare (D-181).
|
|
33
|
+
*/
|
|
34
|
+
function scopesAgree(a, b) {
|
|
35
|
+
return a === null || b === null || taskIdsMatch(a, b);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Flatten a session's terminal events, in log order.
|
|
39
|
+
*
|
|
40
|
+
* "Terminal" is TERMINAL_ROLE_EVENTS: the events that say an agent's turn
|
|
41
|
+
* ended. A dispatch that never reaches one of these is a dispatch whose
|
|
42
|
+
* outcome the log does not hold, and this audit says so rather than assuming
|
|
43
|
+
* the tester ran to completion.
|
|
44
|
+
*/
|
|
45
|
+
export function readTerminalRoleRecords(events) {
|
|
46
|
+
const records = [];
|
|
47
|
+
for (const stored of events) {
|
|
48
|
+
const record = stored.record;
|
|
49
|
+
const field = TERMINAL_ROLE_EVENTS[record.event_type];
|
|
50
|
+
if (!field)
|
|
51
|
+
continue;
|
|
52
|
+
const payload = (record.payload ?? {});
|
|
53
|
+
const role = payloadString(payload, field) ?? record.actor;
|
|
54
|
+
if (!role)
|
|
55
|
+
continue;
|
|
56
|
+
records.push({
|
|
57
|
+
eventId: stored.event_id,
|
|
58
|
+
ts: record.ts ?? '',
|
|
59
|
+
taskId: eventTaskId(record),
|
|
60
|
+
role,
|
|
61
|
+
eventType: record.event_type,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return records;
|
|
65
|
+
}
|
|
66
|
+
function readGateRecords(events) {
|
|
67
|
+
const records = [];
|
|
68
|
+
for (const stored of events) {
|
|
69
|
+
const record = stored.record;
|
|
70
|
+
if (record.event_type !== TEST_GATE_EVENT)
|
|
71
|
+
continue;
|
|
72
|
+
const payload = (record.payload ?? {});
|
|
73
|
+
records.push({
|
|
74
|
+
eventId: stored.event_id,
|
|
75
|
+
ts: record.ts ?? '',
|
|
76
|
+
taskId: eventTaskId(record),
|
|
77
|
+
results: payload.results,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return records;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The latest dispatch of `role` at or before `ts`, on a task scope that
|
|
84
|
+
* agrees.
|
|
85
|
+
*
|
|
86
|
+
* "At or before" is dispatchAudit.ts's argument on this axis too: a tester
|
|
87
|
+
* dispatched after the gate ran cannot have written what the gate graded, and
|
|
88
|
+
* taking the newest dispatch in the session would let exactly that launder a
|
|
89
|
+
* violation into a pass. The task axis is the same argument on the other
|
|
90
|
+
* coordinate — evidence about one task vouches for nothing about another
|
|
91
|
+
* (D-172).
|
|
92
|
+
*/
|
|
93
|
+
function latestDispatchBefore(dispatches, role, ts, scope) {
|
|
94
|
+
let best = null;
|
|
95
|
+
for (const dispatch of dispatches) {
|
|
96
|
+
if (dispatch.role !== role)
|
|
97
|
+
continue;
|
|
98
|
+
if (dispatch.ts > ts)
|
|
99
|
+
continue;
|
|
100
|
+
if (!scopesAgree(dispatch.taskId, scope))
|
|
101
|
+
continue;
|
|
102
|
+
if (!best || dispatch.ts >= best.ts)
|
|
103
|
+
best = dispatch;
|
|
104
|
+
}
|
|
105
|
+
return best;
|
|
106
|
+
}
|
|
107
|
+
function noPairsCheck() {
|
|
108
|
+
return {
|
|
109
|
+
taskId: null,
|
|
110
|
+
worker: '*',
|
|
111
|
+
auditor: '*',
|
|
112
|
+
status: 'unverifiable',
|
|
113
|
+
gateEventId: null,
|
|
114
|
+
checksRun: 0,
|
|
115
|
+
workerEventId: null,
|
|
116
|
+
auditorEventId: null,
|
|
117
|
+
detail: 'crosscheck.yml declares no role_isolation.pairs, so this audit asserts nothing. A policy that names no roles cannot be satisfied or violated, and reporting that as a pass would advertise a check nobody configured.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Assert crosscheck.yml's `role_isolation` against what the log records: for
|
|
122
|
+
* every test gate, was the auditor role dispatched in a turn of its own,
|
|
123
|
+
* before the gate, on this task, and did it report?
|
|
124
|
+
*
|
|
125
|
+
* Every gate is checked, not just the last one. A task that got it wrong once
|
|
126
|
+
* and right on the re-run did get it wrong once.
|
|
127
|
+
*/
|
|
128
|
+
export function checkTesterIsolation(events, pairs, options) {
|
|
129
|
+
const taskId = options.taskId ?? null;
|
|
130
|
+
// D-181: `--task` is answered across both spellings of the id, so a gate
|
|
131
|
+
// logged qualified is not dropped from a bare-spelled scope.
|
|
132
|
+
const inScope = (scope) => (r) => r.taskId !== null && taskIdsMatch(r.taskId, scope);
|
|
133
|
+
const allDispatches = readDispatchRecords(events);
|
|
134
|
+
const dispatches = taskId ? allDispatches.filter(inScope(taskId)) : allDispatches;
|
|
135
|
+
const allGates = readGateRecords(events);
|
|
136
|
+
const gates = taskId ? allGates.filter(inScope(taskId)) : allGates;
|
|
137
|
+
const terminals = readTerminalRoleRecords(events);
|
|
138
|
+
const base = {
|
|
139
|
+
sessionId: options.sessionId,
|
|
140
|
+
taskId,
|
|
141
|
+
gatesExamined: gates.length,
|
|
142
|
+
dispatchesExamined: dispatches.length,
|
|
143
|
+
};
|
|
144
|
+
if (pairs.length === 0) {
|
|
145
|
+
return { ...base, checks: [noPairsCheck()], ok: false };
|
|
146
|
+
}
|
|
147
|
+
const checks = [];
|
|
148
|
+
for (const gate of gates) {
|
|
149
|
+
for (const pair of pairs) {
|
|
150
|
+
checks.push(checkOneGate(pair, gate, dispatches, terminals));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// No gate at all is not a clean run. It is a session that never reached the
|
|
154
|
+
// moment this audit reads, and saying `ok` about it would answer a question
|
|
155
|
+
// the log never posed.
|
|
156
|
+
if (gates.length === 0) {
|
|
157
|
+
for (const pair of pairs) {
|
|
158
|
+
checks.push({
|
|
159
|
+
taskId,
|
|
160
|
+
worker: pair.worker,
|
|
161
|
+
auditor: pair.auditor,
|
|
162
|
+
status: 'unverifiable',
|
|
163
|
+
gateEventId: null,
|
|
164
|
+
checksRun: 0,
|
|
165
|
+
workerEventId: null,
|
|
166
|
+
auditorEventId: null,
|
|
167
|
+
detail: `No ${TEST_GATE_EVENT} event in scope, so nothing here says whether ${pair.auditor} graded ${pair.worker}'s work.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
...base,
|
|
173
|
+
checks,
|
|
174
|
+
ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function checkOneGate(pair, gate, dispatches, terminals) {
|
|
178
|
+
const base = {
|
|
179
|
+
taskId: gate.taskId,
|
|
180
|
+
worker: pair.worker,
|
|
181
|
+
auditor: pair.auditor,
|
|
182
|
+
gateEventId: gate.eventId,
|
|
183
|
+
workerEventId: null,
|
|
184
|
+
auditorEventId: null,
|
|
185
|
+
};
|
|
186
|
+
if (gate.taskId === null) {
|
|
187
|
+
return {
|
|
188
|
+
...base,
|
|
189
|
+
status: 'unverifiable',
|
|
190
|
+
checksRun: 0,
|
|
191
|
+
detail: `The ${TEST_GATE_EVENT} at ${gate.ts} names no task, so no dispatch can be paired with it.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (!Array.isArray(gate.results)) {
|
|
195
|
+
return {
|
|
196
|
+
...base,
|
|
197
|
+
status: 'unverifiable',
|
|
198
|
+
checksRun: 0,
|
|
199
|
+
detail: `The ${TEST_GATE_EVENT} for ${gate.taskId} records no result list, so how much was graded is unknown.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const checksRun = gate.results.length;
|
|
203
|
+
if (checksRun === 0) {
|
|
204
|
+
// Nothing ran, so nothing was self-graded. This is the one status that
|
|
205
|
+
// passes without evidence, and it is safe precisely because there is no
|
|
206
|
+
// verdict here for a coder to have written in its own favour.
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
status: 'not-applicable',
|
|
210
|
+
checksRun: 0,
|
|
211
|
+
detail: `The ${TEST_GATE_EVENT} for ${gate.taskId} ran no test checks, so there was no verdict to grade.`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const auditor = latestDispatchBefore(dispatches, pair.auditor, gate.ts, gate.taskId);
|
|
215
|
+
if (!auditor) {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
status: 'violation',
|
|
219
|
+
checksRun,
|
|
220
|
+
detail: `No ${pair.auditor} was dispatched at or before the test gate for ${gate.taskId}, so the ${checksRun} test check(s) it graded were written in some other role's turn — on this pipeline, ${pair.worker}'s.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
base.auditorEventId = auditor.eventId;
|
|
224
|
+
const worker = latestDispatchBefore(dispatches, pair.worker, auditor.ts, auditor.taskId);
|
|
225
|
+
if (!worker) {
|
|
226
|
+
return {
|
|
227
|
+
...base,
|
|
228
|
+
status: 'unverifiable',
|
|
229
|
+
checksRun,
|
|
230
|
+
detail: `${pair.auditor} was dispatched for ${gate.taskId} with no preceding ${pair.worker} dispatch to have been isolated from.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
base.workerEventId = worker.eventId;
|
|
234
|
+
// A shared agent id is one turn wearing two role names, which is the exact
|
|
235
|
+
// thing the pair forbids. A MISSING id is not evidence of anything: the
|
|
236
|
+
// dispatch contract does not require `agent_id`, so demanding it would make
|
|
237
|
+
// every real log unverifiable and teach the operator to ignore this audit.
|
|
238
|
+
if (auditor.agentId && worker.agentId && auditor.agentId === worker.agentId) {
|
|
239
|
+
return {
|
|
240
|
+
...base,
|
|
241
|
+
status: 'violation',
|
|
242
|
+
checksRun,
|
|
243
|
+
detail: `The ${pair.worker} and ${pair.auditor} dispatches for ${gate.taskId} share agent id ${auditor.agentId}, so the tests were graded in the turn that wrote the code.`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const reported = terminals.find((t) => t.role === pair.auditor &&
|
|
247
|
+
t.ts >= auditor.ts &&
|
|
248
|
+
t.ts <= gate.ts &&
|
|
249
|
+
scopesAgree(t.taskId, gate.taskId));
|
|
250
|
+
if (!reported) {
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
status: 'unverifiable',
|
|
254
|
+
checksRun,
|
|
255
|
+
detail: `${pair.auditor} was dispatched for ${gate.taskId} and never reported before the gate ran, so the log does not say whose work the gate graded.`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
...base,
|
|
260
|
+
status: 'ok',
|
|
261
|
+
checksRun,
|
|
262
|
+
detail: `${pair.auditor} was dispatched for ${gate.taskId} after ${pair.worker} and reported (${reported.eventType}) before the gate graded ${checksRun} test check(s).`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants as osConstants } from 'node:os';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
4
|
+
const TAIL_LINES = 50;
|
|
5
|
+
/**
|
|
6
|
+
* How much of a check's output survives, in UTF-16 code units.
|
|
7
|
+
*
|
|
8
|
+
* `TAIL_LINES` bounds lines, and a bound on lines is not a bound on size: one
|
|
9
|
+
* `--reporter=json` run, one minified bundle, one long `set -x` trace with no
|
|
10
|
+
* newline in it is a single "line", so the line bound returns the whole thing
|
|
11
|
+
* (D-189). Two things then have no ceiling. The `tail` goes verbatim into the
|
|
12
|
+
* `testgate-result` event, and the event log is append-only — an unbounded
|
|
13
|
+
* field there is written once and kept forever. And the output is accumulated
|
|
14
|
+
* in the orchestrator's own heap while the check runs, which the per-command
|
|
15
|
+
* timeout does not bound: a check that floods fills memory long before five
|
|
16
|
+
* minutes are up, and an orchestrator that dies of it writes no gate event at
|
|
17
|
+
* all. Generous on purpose — 50 lines of real test output fit inside this with
|
|
18
|
+
* room to spare, and output that does not was never going to be read as lines.
|
|
19
|
+
*/
|
|
20
|
+
const TAIL_MAX_CHARS = 64 * 1024;
|
|
21
|
+
const DROPPED_NOTE = '[testgate] earlier output dropped — this is the tail, not the whole run';
|
|
22
|
+
const TIMEOUT_EXIT_CODE = -1;
|
|
23
|
+
function tailLines(text, n) {
|
|
24
|
+
// Drop one trailing newline first so a command's final real line doesn't
|
|
25
|
+
// show up as a spurious empty "line" that pushes real content out of the tail.
|
|
26
|
+
const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
27
|
+
const lines = trimmed.split('\n');
|
|
28
|
+
return lines.slice(Math.max(0, lines.length - n)).join('\n');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Name the signal a check died from, if it died from one.
|
|
32
|
+
*
|
|
33
|
+
* Two shapes reach us, because checks run through a shell (`shell: true`):
|
|
34
|
+
* the shell itself signalled gives `signal` directly, while a signalled
|
|
35
|
+
* *grandchild* is reported by the shell as exit code 128+N. Both used to land
|
|
36
|
+
* in the results as a bare number with an empty tail, which reads as "the
|
|
37
|
+
* tests failed" and sends the reader to the wrong subsystem entirely — the
|
|
38
|
+
* mistake D-47 records. A crash is not a test failure and must not look like one.
|
|
39
|
+
*
|
|
40
|
+
* The 128+N branch is an inference, not an observation: a command free to pick
|
|
41
|
+
* its own exit codes may return 137 meaning something of its own, and nothing
|
|
42
|
+
* in the exit status distinguishes that from a shell reporting SIGKILL. The
|
|
43
|
+
* shell throws that information away before we see it. So the note for that
|
|
44
|
+
* branch is worded as the reading it is, and the caller keeps the raw code.
|
|
45
|
+
*/
|
|
46
|
+
function signalNote(code, signal) {
|
|
47
|
+
if (signal !== null) {
|
|
48
|
+
return `[testgate] the check process was killed by ${signal} — it crashed, it did not fail`;
|
|
49
|
+
}
|
|
50
|
+
if (code !== null && code > 128 && code < 128 + 65) {
|
|
51
|
+
const number = code - 128;
|
|
52
|
+
const name = Object.entries(osConstants.signals).find(([, n]) => n === number)?.[0];
|
|
53
|
+
if (name !== undefined) {
|
|
54
|
+
return (`[testgate] exit code ${code} is the shell's convention for a command killed by ` +
|
|
55
|
+
`${name} (128+${number}) — read it as a crash, not a test failure, unless this check ` +
|
|
56
|
+
'is known to use that exit code itself');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run one check command in its own process group (POSIX `setsid`, via
|
|
63
|
+
* `detached: true`) so a timeout can kill the WHOLE group — `process.kill`
|
|
64
|
+
* with a negative pid signals every process in that group, not just the
|
|
65
|
+
* immediate shell child. A plain per-child kill (e.g. execFileSync's
|
|
66
|
+
* built-in `timeout`) leaves grandchildren the shell spawned (background
|
|
67
|
+
* jobs, long-lived children) running as orphans after the parent shell
|
|
68
|
+
* dies — a real leak in a 24/7 factory (reproduced: manual kill -9 needed).
|
|
69
|
+
*/
|
|
70
|
+
function runOne(check, cwd, timeoutMs) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const child = spawn(check.cmd, {
|
|
73
|
+
cwd,
|
|
74
|
+
shell: true,
|
|
75
|
+
detached: true,
|
|
76
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
77
|
+
});
|
|
78
|
+
let output = '';
|
|
79
|
+
let dropped = false;
|
|
80
|
+
let timedOut = false;
|
|
81
|
+
/**
|
|
82
|
+
* Keep the last `TAIL_MAX_CHARS` and drop the rest as it arrives. Trimming
|
|
83
|
+
* at the end would bound the field and not the buffer, and the buffer is
|
|
84
|
+
* the half that can take the orchestrator down with it.
|
|
85
|
+
*/
|
|
86
|
+
const absorb = (chunk) => {
|
|
87
|
+
output += chunk.toString('utf8');
|
|
88
|
+
if (output.length > TAIL_MAX_CHARS) {
|
|
89
|
+
output = output.slice(-TAIL_MAX_CHARS);
|
|
90
|
+
dropped = true;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/** The reported tail, admitting the drop when there was one. */
|
|
94
|
+
const tailOf = (text) => {
|
|
95
|
+
const kept = tailLines(text, TAIL_LINES);
|
|
96
|
+
return dropped ? `${DROPPED_NOTE}\n${kept}` : kept;
|
|
97
|
+
};
|
|
98
|
+
child.stdout?.on('data', absorb);
|
|
99
|
+
child.stderr?.on('data', absorb);
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
timedOut = true;
|
|
102
|
+
if (child.pid !== undefined) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(-child.pid, 'SIGKILL'); // negative pid -> whole process group
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Group already gone (command finished between the timer firing and here) — fine.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
child.on('error', (err) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
resolve({
|
|
114
|
+
name: check.name,
|
|
115
|
+
pass: false,
|
|
116
|
+
exitCode: 1,
|
|
117
|
+
tail: tailOf(err.message),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
child.on('close', (code, signal) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
if (timedOut) {
|
|
123
|
+
const timeoutNote = `[testgate] "${check.name}" timed out after ${timeoutMs}ms — process group killed`;
|
|
124
|
+
resolve({
|
|
125
|
+
name: check.name,
|
|
126
|
+
pass: false,
|
|
127
|
+
exitCode: TIMEOUT_EXIT_CODE,
|
|
128
|
+
tail: tailOf(output ? `${output}\n${timeoutNote}` : timeoutNote),
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const exitCode = code !== null ? code : signal ? -1 : 1;
|
|
133
|
+
const note = signalNote(code, signal);
|
|
134
|
+
const annotated = note === null ? output : output ? `${output}\n${note}` : note;
|
|
135
|
+
resolve({
|
|
136
|
+
name: check.name,
|
|
137
|
+
pass: exitCode === 0,
|
|
138
|
+
exitCode,
|
|
139
|
+
tail: tailOf(annotated),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Run configured check commands (test/typecheck/lint/coverage/…) sequentially
|
|
146
|
+
* in a worktree, one process per command, with a per-command timeout. First
|
|
147
|
+
* failure short-circuits the remaining checks unless `runAll` is set. Thin —
|
|
148
|
+
* no test-output parsing beyond the process exit code.
|
|
149
|
+
*/
|
|
150
|
+
export async function run(checks, opts) {
|
|
151
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
152
|
+
const results = [];
|
|
153
|
+
for (const check of checks) {
|
|
154
|
+
const result = await runOne(check, opts.cwd, timeoutMs);
|
|
155
|
+
results.push(result);
|
|
156
|
+
if (!result.pass && !opts.runAll)
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
return { results, pass: results.length > 0 ? results.every((r) => r.pass) : true };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Cumulative regression gate (architecture §11): the epic's single test
|
|
163
|
+
* command, standing in for "every previously merged task's tests in the
|
|
164
|
+
* epic" — the caller is responsible for pointing `epicTestCmd` at whatever
|
|
165
|
+
* aggregate command covers that (e.g. the epic's full suite), this module
|
|
166
|
+
* has no opinion on what's inside it. A thin wrapper over `run`, not a
|
|
167
|
+
* separate code path.
|
|
168
|
+
*/
|
|
169
|
+
export async function runCumulative(epicTestCmd, opts) {
|
|
170
|
+
return run([{ name: 'cumulative', cmd: epicTestCmd }], opts);
|
|
171
|
+
}
|