@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,106 @@
|
|
|
1
|
+
// The PreToolUse decision, on its own, so that the hot path can hold it
|
|
2
|
+
// without holding the rest of the orchestrator.
|
|
3
|
+
//
|
|
4
|
+
// `.claude/hooks/guard.sh` fires on every Bash/Write/Edit/MultiEdit/
|
|
5
|
+
// NotebookEdit call an agent makes, which makes whatever it execs the most
|
|
6
|
+
// frequently run code in this repo by orders of magnitude. It used to exec
|
|
7
|
+
// `dist/cli.js policy hook`; cli.ts imports the whole orchestrator at module
|
|
8
|
+
// scope, `db/projector.js` and so drizzle-orm among it, and that graph cost
|
|
9
|
+
// ~1.3s to load in front of ~39ms of policy work — a database layer loaded
|
|
10
|
+
// and thrown away once per guarded action. Splitting the decision out lets
|
|
11
|
+
// `policyHook.ts` be an entry point whose imports are only what deciding
|
|
12
|
+
// actually needs, while `cli.ts` keeps `smith policy hook` by calling the
|
|
13
|
+
// same function rather than a second copy of it.
|
|
14
|
+
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
|
|
15
|
+
import { activeSandboxFor } from './sandbox.js';
|
|
16
|
+
/**
|
|
17
|
+
* Reads one PreToolUse payload and answers with the envelope to print, or
|
|
18
|
+
* `null` to print nothing.
|
|
19
|
+
*
|
|
20
|
+
* Two outcomes, and the caller signals them apart by exit code:
|
|
21
|
+
* - a reachable decision: exit 0, and either this envelope on stdout or
|
|
22
|
+
* silence.
|
|
23
|
+
* - could not reach a decision at all: a throw, left to propagate, which
|
|
24
|
+
* the caller turns into a non-zero exit.
|
|
25
|
+
*
|
|
26
|
+
* A malformed payload takes the second path on purpose. guard.sh's own
|
|
27
|
+
* `extract_field` used to return '' on anything it could not parse, which
|
|
28
|
+
* read as "no tool_name" and let every rule fall through as a silent allow —
|
|
29
|
+
* invisibly, since a BSD-vs-GNU `sed` incompatibility meant this had been
|
|
30
|
+
* happening on every macOS run since Phase 2 (see guard.sh's header).
|
|
31
|
+
* "Cannot parse the input" and "nothing to inspect" must never look the same
|
|
32
|
+
* again: the first is failure, the second is a real allow, and only guard.sh's
|
|
33
|
+
* fail-closed handling of a non-zero exit may turn "failure" into a denial.
|
|
34
|
+
*/
|
|
35
|
+
export function decideHookPayload(raw, fallbackCwd) {
|
|
36
|
+
const payload = JSON.parse(raw);
|
|
37
|
+
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
38
|
+
const command = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
|
|
39
|
+
// A `Write`/`Edit`/`MultiEdit` payload carries no command; its target is
|
|
40
|
+
// `tool_input.file_path`, absolute. Bash was the only tool worth inspecting
|
|
41
|
+
// while every leased role was a judge, since judges hold no file tools and a
|
|
42
|
+
// shell was their only write path. A tester holds both, so watching only
|
|
43
|
+
// Bash would leave the rule to be routed around with the tool the role was
|
|
44
|
+
// handed. Same shape as `command`: a missing or non-string field reads as
|
|
45
|
+
// absent, never as a guess.
|
|
46
|
+
const filePath = typeof payload.tool_input?.file_path === 'string' ? payload.tool_input.file_path : null;
|
|
47
|
+
// Branch and repo root come from where the command would actually run, which
|
|
48
|
+
// the PreToolUse payload carries as `cwd`. Resolving them from this binary's
|
|
49
|
+
// own checkout instead would be wrong in the case this factory spends most of
|
|
50
|
+
// its time in: guard.sh always execs the main clone's dist, while the session
|
|
51
|
+
// issuing the command is usually inside a per-task git worktree on a
|
|
52
|
+
// different branch. That gap is not academic — `git rebase` on
|
|
53
|
+
// smith/<epic>/integration is a rule-5 denial, and the merge queue performs
|
|
54
|
+
// rebases from a worktree standing on exactly that branch while the main
|
|
55
|
+
// clone stands elsewhere. The old bash hook got this right for free by
|
|
56
|
+
// running `git rev-parse` in its own inherited cwd; the port has to ask for
|
|
57
|
+
// it. `fallbackCwd` covers a payload without the field (a hand-driven
|
|
58
|
+
// invocation, or an older client), and a cwd outside any repo resolves to
|
|
59
|
+
// ''/null, which denies an out-of-bounds removal rather than allowing it.
|
|
60
|
+
const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : fallbackCwd;
|
|
61
|
+
const context = {
|
|
62
|
+
toolName,
|
|
63
|
+
command,
|
|
64
|
+
branch: detectCurrentBranch(cwd),
|
|
65
|
+
repoRoot: detectRepoRoot(cwd),
|
|
66
|
+
// Resolved from the same `cwd`, and for the same reason: the lease is keyed
|
|
67
|
+
// by the worktree a judge was handed, and `cwd` is the only thing in a
|
|
68
|
+
// PreToolUse payload that says where the command will run. No lease is the
|
|
69
|
+
// ordinary case and costs one directory read.
|
|
70
|
+
sandbox: activeSandboxFor(cwd),
|
|
71
|
+
filePath,
|
|
72
|
+
};
|
|
73
|
+
const decision = evaluateCommand(context, loadGuardrailPolicy());
|
|
74
|
+
if (decision.allowed) {
|
|
75
|
+
// Allow prints NOTHING, on purpose, and this is not a cosmetic choice.
|
|
76
|
+
// Claude Code reads an explicit `permissionDecision: "allow"` from a hook
|
|
77
|
+
// as "skip the permission system for this call" — it outranks the
|
|
78
|
+
// operator's own `permissions.deny` list and suppresses the approval
|
|
79
|
+
// prompt. A guard hook that emitted it on every command it did not
|
|
80
|
+
// recognise would therefore not be a guard at all: it would be a blanket
|
|
81
|
+
// auto-approver for everything outside guardrails.yml's rules, which is a
|
|
82
|
+
// strictly wider grant than the hook it replaces ever had. Empty stdout is
|
|
83
|
+
// the protocol's "no opinion, carry on with the normal permission flow",
|
|
84
|
+
// which is exactly what this layer means when no rule fires. The exit code
|
|
85
|
+
// still separates "no opinion" (0, silent) from "could not decide"
|
|
86
|
+
// (non-zero, which guard.sh turns into a deny), so silence here is never
|
|
87
|
+
// load-bearing the way the old hook's was.
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
// guard.sh only ever surfaced one reason: each rule block()ed and exited
|
|
91
|
+
// immediately on its own match, sequentially, so the first rule in guard.sh's
|
|
92
|
+
// 1-6 order to fire is the only one an agent ever saw. evaluateCommand's
|
|
93
|
+
// checks array preserves that same order, so violations[0] reproduces
|
|
94
|
+
// guard.sh's exact visible behaviour even though evaluateCommand (policy
|
|
95
|
+
// check's diagnostic output) reports every rule that tripped, not just the
|
|
96
|
+
// first.
|
|
97
|
+
const first = decision.violations[0];
|
|
98
|
+
const reason = first ? first.reason : 'guardrails.yml denied this command.';
|
|
99
|
+
return {
|
|
100
|
+
hookSpecificOutput: {
|
|
101
|
+
hookEventName: 'PreToolUse',
|
|
102
|
+
permissionDecision: 'deny',
|
|
103
|
+
permissionDecisionReason: `BLOCKED: ${reason}`,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { runGit as git, runGitRaw } from './git.js';
|
|
6
|
+
export class ImmutabilityError extends SmithError {
|
|
7
|
+
}
|
|
8
|
+
/** The taxonomy class raised when a judge moved the worktree it was judging. */
|
|
9
|
+
export const JUDGE_MUTATION_CODE = 'contract.judge-mutation';
|
|
10
|
+
const PORCELAIN_STATUS_WIDTH = 3; // "XY " — two status chars plus the separator
|
|
11
|
+
const HASH_LENGTH = 12;
|
|
12
|
+
const GONE = 'gone';
|
|
13
|
+
const HEAD_PATH = 'HEAD';
|
|
14
|
+
function assertGitWorktree(worktreeDir) {
|
|
15
|
+
try {
|
|
16
|
+
git(worktreeDir, ['rev-parse', '--is-inside-work-tree']);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
throw new ImmutabilityError('immutability.not-a-git-worktree', `${worktreeDir} is not a git worktree: ${err instanceof Error ? err.message : String(err)}`, { worktreeDir });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function headSha(worktreeDir) {
|
|
23
|
+
try {
|
|
24
|
+
return git(worktreeDir, ['rev-parse', 'HEAD']);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null; // unborn branch — a worktree with no commits yet
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function currentBranch(worktreeDir) {
|
|
31
|
+
try {
|
|
32
|
+
return git(worktreeDir, ['branch', '--show-current']) || null; // empty when detached
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function hashFile(worktreeDir, relPath) {
|
|
39
|
+
try {
|
|
40
|
+
return createHash('sha256')
|
|
41
|
+
.update(readFileSync(path.join(worktreeDir, relPath)))
|
|
42
|
+
.digest('hex')
|
|
43
|
+
.slice(0, HASH_LENGTH);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null; // deleted, a directory, or unreadable — all "no bytes here"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every path git currently reports as dirty or untracked, `-z`-parsed so
|
|
51
|
+
* spaces and newlines in filenames survive. Renames and copies emit two
|
|
52
|
+
* fields (destination then source); both are recorded, since a judge that
|
|
53
|
+
* renamed a file moved two paths.
|
|
54
|
+
*/
|
|
55
|
+
function statusEntries(worktreeDir) {
|
|
56
|
+
const output = runGitRaw(worktreeDir, [
|
|
57
|
+
'status',
|
|
58
|
+
'--porcelain=v1',
|
|
59
|
+
'-z',
|
|
60
|
+
'--untracked-files=all',
|
|
61
|
+
]);
|
|
62
|
+
const fields = output.split('\0');
|
|
63
|
+
const entries = {};
|
|
64
|
+
for (let i = 0; i < fields.length; i++) {
|
|
65
|
+
const field = fields[i];
|
|
66
|
+
if (field.length <= PORCELAIN_STATUS_WIDTH)
|
|
67
|
+
continue;
|
|
68
|
+
const status = field.slice(0, 2);
|
|
69
|
+
const target = field.slice(PORCELAIN_STATUS_WIDTH);
|
|
70
|
+
entries[target] = { status, hash: hashFile(worktreeDir, target) };
|
|
71
|
+
if (status.startsWith('R') || status.startsWith('C')) {
|
|
72
|
+
const source = fields[i + 1];
|
|
73
|
+
if (source)
|
|
74
|
+
entries[source] = { status, hash: hashFile(worktreeDir, source) };
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The state a judge must hand back untouched: HEAD, the checked-out branch,
|
|
82
|
+
* and every dirty/untracked path with a hash of its bytes.
|
|
83
|
+
*
|
|
84
|
+
* Two deliberate choices, both load-bearing:
|
|
85
|
+
*
|
|
86
|
+
* 1. **Content hashes, not just the status list.** Six roles (reviewer,
|
|
87
|
+
* verifier, grader, spec-reviewer, security-reviewer, uiux) are read-only
|
|
88
|
+
* in prose and hold `Bash` in fact (agent-interviews.md N-10), so the
|
|
89
|
+
* guarantee needs a check. A status-only fingerprint has a hole exactly
|
|
90
|
+
* where it matters: the coder leaves `src/parse.ts` dirty, the judge edits
|
|
91
|
+
* it again, and the porcelain line reads `" M src/parse.ts"` both times.
|
|
92
|
+
* The hash closes it.
|
|
93
|
+
* 2. **No `--ignored`.** Judges run the suite — that is what `Bash` is for —
|
|
94
|
+
* and running it writes `node_modules/`, `dist/`, coverage caches. A guard
|
|
95
|
+
* that fired on those would be turned off within a day. Widening
|
|
96
|
+
* `.gitignore` is not an escape hatch: that edit is itself a tracked-file
|
|
97
|
+
* change, and ignore rules never apply to already-tracked files.
|
|
98
|
+
*
|
|
99
|
+
* The remaining hole is honest and small: an edit reverted byte-for-byte
|
|
100
|
+
* before the judge exits is invisible to any before/after comparison.
|
|
101
|
+
*/
|
|
102
|
+
export function fingerprintWorktree(worktreeDir) {
|
|
103
|
+
assertGitWorktree(worktreeDir);
|
|
104
|
+
return {
|
|
105
|
+
head: headSha(worktreeDir),
|
|
106
|
+
branch: currentBranch(worktreeDir),
|
|
107
|
+
entries: statusEntries(worktreeDir),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function describe(entry) {
|
|
111
|
+
return `${entry.status} ${entry.hash ?? GONE}`;
|
|
112
|
+
}
|
|
113
|
+
function assertFingerprint(value) {
|
|
114
|
+
if (value === null ||
|
|
115
|
+
typeof value !== 'object' ||
|
|
116
|
+
typeof value.entries !== 'object' ||
|
|
117
|
+
value.entries === null ||
|
|
118
|
+
!('head' in value)) {
|
|
119
|
+
throw new ImmutabilityError('immutability.invalid-fingerprint', 'The before-fingerprint is not a WorktreeFingerprint ({ head, branch, entries }).', { received: value === null ? 'null' : typeof value });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Compare the worktree as it stands now against the fingerprint taken before
|
|
124
|
+
* a judge ran. Any difference is a violation: the judge is not the coder, and
|
|
125
|
+
* the one path it may write is its own artifact under `state/results/`, which
|
|
126
|
+
* lives outside the worktree.
|
|
127
|
+
*/
|
|
128
|
+
export function checkWorktreeImmutable(worktreeDir, before) {
|
|
129
|
+
assertFingerprint(before);
|
|
130
|
+
const after = fingerprintWorktree(worktreeDir);
|
|
131
|
+
const drift = [];
|
|
132
|
+
if (before.head !== after.head) {
|
|
133
|
+
drift.push({ kind: 'head-moved', path: HEAD_PATH, before: before.head, after: after.head });
|
|
134
|
+
}
|
|
135
|
+
if (before.branch !== after.branch) {
|
|
136
|
+
drift.push({
|
|
137
|
+
kind: 'branch-switched',
|
|
138
|
+
path: HEAD_PATH,
|
|
139
|
+
before: before.branch,
|
|
140
|
+
after: after.branch,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const key of [
|
|
144
|
+
...new Set([...Object.keys(before.entries), ...Object.keys(after.entries)]),
|
|
145
|
+
].sort()) {
|
|
146
|
+
const wasEntry = before.entries[key];
|
|
147
|
+
const isEntry = after.entries[key];
|
|
148
|
+
if (!wasEntry && isEntry) {
|
|
149
|
+
drift.push({ kind: 'dirtied', path: key, before: null, after: describe(isEntry) });
|
|
150
|
+
}
|
|
151
|
+
else if (wasEntry && !isEntry) {
|
|
152
|
+
drift.push({ kind: 'reverted', path: key, before: describe(wasEntry), after: null });
|
|
153
|
+
}
|
|
154
|
+
else if (wasEntry && isEntry && describe(wasEntry) !== describe(isEntry)) {
|
|
155
|
+
drift.push({
|
|
156
|
+
kind: 'modified',
|
|
157
|
+
path: key,
|
|
158
|
+
before: describe(wasEntry),
|
|
159
|
+
after: describe(isEntry),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const unchanged = drift.length === 0;
|
|
164
|
+
return {
|
|
165
|
+
unchanged,
|
|
166
|
+
drift,
|
|
167
|
+
violation: unchanged
|
|
168
|
+
? null
|
|
169
|
+
: { error: JUDGE_MUTATION_CODE, paths: [...new Set(drift.map((d) => d.path))] },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a path claim cannot say, said here.
|
|
3
|
+
*
|
|
4
|
+
* `claims.ts` answers "did two tasks write the same file". That question has a
|
|
5
|
+
* blind spot with a name: task A changes `parse()`'s signature in `src/a.ts`,
|
|
6
|
+
* task B calls `parse()` from `src/b.ts`, the two claim lists are disjoint,
|
|
7
|
+
* every gate is green, and integration is where the factory finds out. The
|
|
8
|
+
* conflict was never in a file — it lived on the edge between two files, which
|
|
9
|
+
* is exactly what `symbols.ts` reads.
|
|
10
|
+
*
|
|
11
|
+
* Two checks, and the difference between them is the difference between a risk
|
|
12
|
+
* and a fact:
|
|
13
|
+
*
|
|
14
|
+
* - `waveImpact` runs BEFORE dispatch, over declarations. It can only see that
|
|
15
|
+
* two tasks in one wave sit on either end of a compile-time edge. That is a
|
|
16
|
+
* reason to order them, not evidence that anything is wrong, so it reports
|
|
17
|
+
* `coupled` and stops the wave from running them in parallel.
|
|
18
|
+
* - `exportImpact` runs AFTER the work, over a diff. Here an export that was
|
|
19
|
+
* removed while a file outside the claims still imports it is not a risk —
|
|
20
|
+
* it is a break, and it is provable. A changed signature is weaker: this
|
|
21
|
+
* module reads text, not types, so it says `possible` and means it.
|
|
22
|
+
*
|
|
23
|
+
* Holes are reported, never fatal. The scanner has blind spots (a `.vue` file,
|
|
24
|
+
* an unterminated literal), and failing a wave for the scanner's limits would
|
|
25
|
+
* teach operators to reach for the override — which costs more than the check
|
|
26
|
+
* was ever worth.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { extname, join } from 'node:path';
|
|
30
|
+
import { claimCoversPath, integrationBranchFor } from './claims.js';
|
|
31
|
+
import { runGit } from './git.js';
|
|
32
|
+
import { DEFAULT_SOURCE_EXTENSIONS, parseModuleFacts, } from './symbols.js';
|
|
33
|
+
/**
|
|
34
|
+
* File -> the task that claims it. First claim wins: a file claimed twice is an
|
|
35
|
+
* overlap, and `validateWave` already refuses that wave for its own reasons.
|
|
36
|
+
*/
|
|
37
|
+
function ownersOf(files, tasks) {
|
|
38
|
+
const owner = new Map();
|
|
39
|
+
for (const task of tasks) {
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (owner.has(file))
|
|
42
|
+
continue;
|
|
43
|
+
if (task.claims.some((claim) => claimCoversPath(claim, file)))
|
|
44
|
+
owner.set(file, task.task_id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return owner;
|
|
48
|
+
}
|
|
49
|
+
export function waveImpact(graph, tasks) {
|
|
50
|
+
// Unreadable files are absent from `modules` but still claimable, and a task
|
|
51
|
+
// that claims one has matched a real file — it is the reading that failed.
|
|
52
|
+
const known = [...graph.modules.keys(), ...graph.unanalyzed].sort();
|
|
53
|
+
const owner = ownersOf(known, tasks);
|
|
54
|
+
const claimed = new Set(owner.values());
|
|
55
|
+
const crossings = new Map();
|
|
56
|
+
const exposure = new Map();
|
|
57
|
+
for (const [exportedBy, edges] of graph.dependents) {
|
|
58
|
+
const producer = owner.get(exportedBy);
|
|
59
|
+
if (producer === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
for (const edge of edges) {
|
|
62
|
+
const consumer = owner.get(edge.from);
|
|
63
|
+
if (consumer === undefined) {
|
|
64
|
+
const key = `${producer} ${exportedBy} ${edge.from}`;
|
|
65
|
+
const seen = exposure.get(key);
|
|
66
|
+
if (seen === undefined) {
|
|
67
|
+
exposure.set(key, {
|
|
68
|
+
producer,
|
|
69
|
+
exportedBy,
|
|
70
|
+
importedBy: edge.from,
|
|
71
|
+
names: new Set(edge.names),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
for (const name of edge.names)
|
|
76
|
+
seen.names.add(name);
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (consumer === producer)
|
|
81
|
+
continue;
|
|
82
|
+
const key = `${producer} ${consumer} ${exportedBy} ${edge.from}`;
|
|
83
|
+
const seen = crossings.get(key);
|
|
84
|
+
if (seen === undefined) {
|
|
85
|
+
crossings.set(key, {
|
|
86
|
+
producer,
|
|
87
|
+
consumer,
|
|
88
|
+
exportedBy,
|
|
89
|
+
importedBy: edge.from,
|
|
90
|
+
symbols: new Set(edge.names),
|
|
91
|
+
typeOnly: edge.typeOnly,
|
|
92
|
+
dynamic: edge.dynamic,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
for (const name of edge.names)
|
|
97
|
+
seen.symbols.add(name);
|
|
98
|
+
// One value import is enough to make the coupling a value coupling.
|
|
99
|
+
seen.typeOnly = seen.typeOnly && edge.typeOnly;
|
|
100
|
+
seen.dynamic = seen.dynamic && edge.dynamic;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const orderedCrossings = [...crossings.values()]
|
|
105
|
+
.map((c) => ({
|
|
106
|
+
producer: c.producer,
|
|
107
|
+
consumer: c.consumer,
|
|
108
|
+
exportedBy: c.exportedBy,
|
|
109
|
+
importedBy: c.importedBy,
|
|
110
|
+
symbols: [...c.symbols].sort(),
|
|
111
|
+
typeOnly: c.typeOnly,
|
|
112
|
+
dynamic: c.dynamic,
|
|
113
|
+
}))
|
|
114
|
+
.sort((a, b) => a.producer.localeCompare(b.producer) ||
|
|
115
|
+
a.consumer.localeCompare(b.consumer) ||
|
|
116
|
+
a.exportedBy.localeCompare(b.exportedBy) ||
|
|
117
|
+
a.importedBy.localeCompare(b.importedBy));
|
|
118
|
+
const orderedExposure = [...exposure.values()]
|
|
119
|
+
.map((e) => ({
|
|
120
|
+
producer: e.producer,
|
|
121
|
+
exportedBy: e.exportedBy,
|
|
122
|
+
importedBy: e.importedBy,
|
|
123
|
+
symbols: [...e.names].sort(),
|
|
124
|
+
}))
|
|
125
|
+
.sort((a, b) => a.producer.localeCompare(b.producer) ||
|
|
126
|
+
a.exportedBy.localeCompare(b.exportedBy) ||
|
|
127
|
+
a.importedBy.localeCompare(b.importedBy));
|
|
128
|
+
const claimsWithoutFiles = tasks
|
|
129
|
+
.filter((task) => !claimed.has(task.task_id))
|
|
130
|
+
.map((task) => task.task_id);
|
|
131
|
+
const unanalyzed = graph.unanalyzed.filter((file) => owner.has(file));
|
|
132
|
+
const unresolved = graph.unresolved.filter((entry) => owner.has(entry.from));
|
|
133
|
+
const hasHoles = unanalyzed.length > 0 || unresolved.length > 0;
|
|
134
|
+
const status = orderedCrossings.length > 0 ? 'coupled' : hasHoles ? 'unverifiable' : 'clean';
|
|
135
|
+
return {
|
|
136
|
+
status,
|
|
137
|
+
ok: status !== 'coupled',
|
|
138
|
+
crossings: orderedCrossings,
|
|
139
|
+
exposure: orderedExposure,
|
|
140
|
+
claimsWithoutFiles,
|
|
141
|
+
unanalyzed,
|
|
142
|
+
unresolved,
|
|
143
|
+
detail: describeWave(status, orderedCrossings, orderedExposure, unanalyzed.length + unresolved.length),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function describeWave(status, crossings, exposure, holes) {
|
|
147
|
+
const parts = [];
|
|
148
|
+
if (status === 'coupled') {
|
|
149
|
+
const pairs = new Set(crossings.map((c) => `${c.producer}/${c.consumer}`));
|
|
150
|
+
parts.push(`${crossings.length} symbol crossing(s) across ${pairs.size} task pair(s): run them in order, not in parallel.`);
|
|
151
|
+
}
|
|
152
|
+
else if (status === 'unverifiable') {
|
|
153
|
+
parts.push('No crossing found, but the graph has holes over this wave - treat the result as partial.');
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
parts.push('No task in this wave imports a symbol another task in this wave exports.');
|
|
157
|
+
}
|
|
158
|
+
if (exposure.length > 0) {
|
|
159
|
+
parts.push(`${exposure.length} file(s) outside the wave import from claimed files.`);
|
|
160
|
+
}
|
|
161
|
+
if (holes > 0)
|
|
162
|
+
parts.push(`${holes} unreadable or unresolved edge(s) over claimed files.`);
|
|
163
|
+
return parts.join(' ');
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The classifier half (P9-3): two texts in, a diff out. Knows nothing about
|
|
167
|
+
* git, which is what lets the wave gate, the merge queue and a test all ask it
|
|
168
|
+
* the same question.
|
|
169
|
+
*/
|
|
170
|
+
export function diffExports(before, after, file) {
|
|
171
|
+
const b = parseModuleFacts(before, file);
|
|
172
|
+
const a = parseModuleFacts(after, file);
|
|
173
|
+
if (b.unterminated || a.unterminated) {
|
|
174
|
+
return { file, removed: [], added: [], signatureChanged: [], unverifiable: true };
|
|
175
|
+
}
|
|
176
|
+
const afterNames = new Set(a.exports);
|
|
177
|
+
const beforeNames = new Set(b.exports);
|
|
178
|
+
return {
|
|
179
|
+
file,
|
|
180
|
+
removed: b.exports.filter((name) => !afterNames.has(name)),
|
|
181
|
+
added: a.exports.filter((name) => !beforeNames.has(name)),
|
|
182
|
+
signatureChanged: b.exports.filter((name) => afterNames.has(name) && b.exportSignatures.get(name) !== a.exportSignatures.get(name)),
|
|
183
|
+
unverifiable: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** A namespace import consumes whatever the module exports, named or not. */
|
|
187
|
+
function consumes(names, symbol) {
|
|
188
|
+
return names.includes('*') || names.includes(symbol);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Whose files broke. `claims` is the acting task's own claim list: an importer
|
|
192
|
+
* the task already owns is not a break, because the same task is free to fix
|
|
193
|
+
* it in the same diff — and usually has.
|
|
194
|
+
*/
|
|
195
|
+
export function exportImpact(graph, diffs, claims) {
|
|
196
|
+
const breaks = [];
|
|
197
|
+
const unverifiable = [];
|
|
198
|
+
for (const diff of diffs) {
|
|
199
|
+
if (diff.unverifiable) {
|
|
200
|
+
unverifiable.push(diff.file);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (diff.removed.length === 0 && diff.signatureChanged.length === 0)
|
|
204
|
+
continue;
|
|
205
|
+
for (const edge of graph.dependents.get(diff.file) ?? []) {
|
|
206
|
+
if (claims.some((claim) => claimCoversPath(claim, edge.from)))
|
|
207
|
+
continue;
|
|
208
|
+
const gone = diff.removed.filter((symbol) => consumes(edge.names, symbol));
|
|
209
|
+
const moved = diff.signatureChanged.filter((symbol) => consumes(edge.names, symbol));
|
|
210
|
+
if (gone.length > 0) {
|
|
211
|
+
breaks.push({
|
|
212
|
+
severity: 'proven',
|
|
213
|
+
reason: 'removed',
|
|
214
|
+
exportedBy: diff.file,
|
|
215
|
+
importedBy: edge.from,
|
|
216
|
+
symbols: gone,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (moved.length > 0) {
|
|
220
|
+
breaks.push({
|
|
221
|
+
severity: 'possible',
|
|
222
|
+
reason: 'signature-changed',
|
|
223
|
+
exportedBy: diff.file,
|
|
224
|
+
importedBy: edge.from,
|
|
225
|
+
symbols: moved,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
breaks.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'proven' ? -1 : 1) ||
|
|
231
|
+
a.exportedBy.localeCompare(b.exportedBy) ||
|
|
232
|
+
a.importedBy.localeCompare(b.importedBy));
|
|
233
|
+
const proven = breaks.filter((entry) => entry.severity === 'proven').length;
|
|
234
|
+
const possible = breaks.length - proven;
|
|
235
|
+
const parts = [];
|
|
236
|
+
if (proven > 0)
|
|
237
|
+
parts.push(`${proven} importer(s) outside the claims lost a symbol they use.`);
|
|
238
|
+
if (possible > 0) {
|
|
239
|
+
parts.push(`${possible} importer(s) use a symbol whose declaration changed shape.`);
|
|
240
|
+
}
|
|
241
|
+
if (proven === 0 && possible === 0) {
|
|
242
|
+
parts.push('No importer outside the claims depends on what moved.');
|
|
243
|
+
}
|
|
244
|
+
if (unverifiable.length > 0)
|
|
245
|
+
parts.push(`Unreadable, so unchecked: ${unverifiable.join(', ')}.`);
|
|
246
|
+
return { ok: proven === 0, breaks, detail: parts.join(' ') };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The collector half (P9-3): read both sides of each changed file out of git.
|
|
250
|
+
*
|
|
251
|
+
* `ls-tree` before `show` on purpose — a file added on this branch is absent
|
|
252
|
+
* from the integration branch, and asking `show` for it would raise an error
|
|
253
|
+
* that means "new file", which is not an error at all.
|
|
254
|
+
*/
|
|
255
|
+
export function collectExportDiffs(worktreeDir, changedFiles) {
|
|
256
|
+
const integrationBranch = integrationBranchFor(worktreeDir);
|
|
257
|
+
const diffs = [];
|
|
258
|
+
for (const file of changedFiles) {
|
|
259
|
+
if (!DEFAULT_SOURCE_EXTENSIONS.includes(extname(file)))
|
|
260
|
+
continue;
|
|
261
|
+
const listed = runGit(worktreeDir, [
|
|
262
|
+
'ls-tree',
|
|
263
|
+
'-r',
|
|
264
|
+
'--name-only',
|
|
265
|
+
integrationBranch,
|
|
266
|
+
'--',
|
|
267
|
+
file,
|
|
268
|
+
]);
|
|
269
|
+
const before = listed === '' ? '' : runGit(worktreeDir, ['show', `${integrationBranch}:${file}`]);
|
|
270
|
+
const absolute = join(worktreeDir, file);
|
|
271
|
+
const after = existsSync(absolute) ? readFileSync(absolute, 'utf8') : '';
|
|
272
|
+
if (before === '' && after === '')
|
|
273
|
+
continue;
|
|
274
|
+
const diff = diffExports(before, after, file);
|
|
275
|
+
const moved = diff.removed.length > 0 || diff.added.length > 0 || diff.signatureChanged.length > 0;
|
|
276
|
+
if (diff.unverifiable || moved)
|
|
277
|
+
diffs.push(diff);
|
|
278
|
+
}
|
|
279
|
+
return diffs;
|
|
280
|
+
}
|