@oscharko-dev/keiko-tools 0.2.8 → 0.2.9

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.
Files changed (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/git-commit-intent-node.d.ts +7 -0
  3. package/dist/git-commit-intent-node.d.ts.map +1 -0
  4. package/dist/git-commit-intent-node.js +42 -0
  5. package/dist/git-merge-gateway.d.ts +95 -0
  6. package/dist/git-merge-gateway.d.ts.map +1 -0
  7. package/dist/git-merge-gateway.js +536 -0
  8. package/dist/git-merge-node.d.ts +17 -0
  9. package/dist/git-merge-node.d.ts.map +1 -0
  10. package/dist/git-merge-node.js +243 -0
  11. package/dist/git-mutation-adapter.d.ts +51 -0
  12. package/dist/git-mutation-adapter.d.ts.map +1 -0
  13. package/dist/git-mutation-adapter.js +207 -0
  14. package/dist/git-mutation-evidence.d.ts +23 -0
  15. package/dist/git-mutation-evidence.d.ts.map +1 -0
  16. package/dist/git-mutation-evidence.js +283 -0
  17. package/dist/git-mutation-node.d.ts +22 -0
  18. package/dist/git-mutation-node.d.ts.map +1 -0
  19. package/dist/git-mutation-node.js +145 -0
  20. package/dist/git-mutation-orchestrator.d.ts +92 -0
  21. package/dist/git-mutation-orchestrator.d.ts.map +1 -0
  22. package/dist/git-mutation-orchestrator.js +321 -0
  23. package/dist/git-mutation-preflight.d.ts +35 -0
  24. package/dist/git-mutation-preflight.d.ts.map +1 -0
  25. package/dist/git-mutation-preflight.js +226 -0
  26. package/dist/git-mutation-taxonomy.d.ts +15 -0
  27. package/dist/git-mutation-taxonomy.d.ts.map +1 -0
  28. package/dist/git-mutation-taxonomy.js +102 -0
  29. package/dist/git-pr-gateway.d.ts +101 -0
  30. package/dist/git-pr-gateway.d.ts.map +1 -0
  31. package/dist/git-pr-gateway.js +487 -0
  32. package/dist/git-pr-node.d.ts +17 -0
  33. package/dist/git-pr-node.d.ts.map +1 -0
  34. package/dist/git-pr-node.js +173 -0
  35. package/dist/git-publish-gateway.d.ts +72 -0
  36. package/dist/git-publish-gateway.d.ts.map +1 -0
  37. package/dist/git-publish-gateway.js +423 -0
  38. package/dist/git-publish-node.d.ts +17 -0
  39. package/dist/git-publish-node.d.ts.map +1 -0
  40. package/dist/git-publish-node.js +107 -0
  41. package/dist/git-worktree-adapter.d.ts +78 -0
  42. package/dist/git-worktree-adapter.d.ts.map +1 -0
  43. package/dist/git-worktree-adapter.js +300 -0
  44. package/dist/git-worktree-snapshot-node.d.ts +31 -0
  45. package/dist/git-worktree-snapshot-node.d.ts.map +1 -0
  46. package/dist/git-worktree-snapshot-node.js +189 -0
  47. package/dist/index.d.ts +9 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +37 -0
  50. package/package.json +9 -5
@@ -0,0 +1,321 @@
1
+ // The governed Git mutation orchestrator (Issue #472, Epic #470) — AC1.
2
+ //
3
+ // This is the single execution authority for governed local Git writes. Every supported mutation
4
+ // follows ONE repeatable path: resolve content-free inputs, run deterministic preflight, produce a
5
+ // content-free preview, evaluate org/repo policy, and — only when preflight passes, policy permits,
6
+ // and any required approval is satisfied — execute through the narrow local adapter and emit a
7
+ // structured result. The descriptive `GitDeliveryActionEnvelope` is always populated through the
8
+ // policy phase (it is the contract artifact preview/evidence consumers read); the `outcome` and
9
+ // `phaseReached` state precisely where enforcement halted and why, with no string parsing required.
10
+ //
11
+ // The orchestrator is deterministic given its injected dependencies (snapshot, clock, id generator,
12
+ // adapter). It performs no IO of its own: git execution lives behind the injected adapter, so the
13
+ // orchestrator is unit-testable with a fake adapter and never opens a parallel child_process path.
14
+ import { evaluateGitPolicy, GIT_DELIVERY_SCHEMA_VERSION, gitDeliveryBranchNameMatchesAny, gitDeliveryRiskClassWithinCeiling, } from "@oscharko-dev/keiko-contracts";
15
+ import { evaluateGitPreflight } from "./git-mutation-preflight.js";
16
+ import { gitMutationCategoryForExecutionResult } from "./git-mutation-taxonomy.js";
17
+ // Convenience accessor: the failure category for any non-success outcome, or undefined for a
18
+ // success / approval hold. Lets consumers branch on category without re-deriving it.
19
+ export function gitMutationOutcomeFailureCategory(outcome) {
20
+ if (outcome.status === "blocked" ||
21
+ outcome.status === "failed" ||
22
+ outcome.status === "recovery-required") {
23
+ return outcome.category;
24
+ }
25
+ return undefined;
26
+ }
27
+ const UTF8 = new TextEncoder();
28
+ // ─── Phase 1: resolve content-free inputs ────────────────────────────────────────────────────
29
+ function deriveResolvedInputs(command, snapshot) {
30
+ switch (command.kind) {
31
+ case "branch-create":
32
+ return {
33
+ kind: "branch-create",
34
+ branchName: command.branchName,
35
+ baseBranchName: command.baseBranchName,
36
+ startPointRefHash: command.startPointRefHash,
37
+ };
38
+ case "branch-switch":
39
+ return { kind: "branch-switch", branchName: command.branchName };
40
+ case "stage":
41
+ return {
42
+ kind: "stage",
43
+ pathCount: command.pathspecs.length,
44
+ includesUntracked: command.includeUntracked,
45
+ };
46
+ case "unstage":
47
+ return { kind: "unstage", pathCount: command.pathspecs.length };
48
+ case "commit":
49
+ return {
50
+ kind: "commit",
51
+ messageByteLength: UTF8.encode(command.message).length,
52
+ stagedPathCount: snapshot.stagedFileCount,
53
+ allowEmptyCommit: command.allowEmpty,
54
+ };
55
+ case "abort":
56
+ return {
57
+ kind: "abort",
58
+ operationToAbort: command.operationToAbort,
59
+ preserveIndexChanges: command.preserveIndexChanges,
60
+ };
61
+ case "recovery":
62
+ return {
63
+ kind: "recovery",
64
+ recoveryStrategyHint: command.recoveryStrategyHint,
65
+ targetRefHash: command.targetRefHash,
66
+ affectedPathCount: command.affectedPathspecs.length,
67
+ };
68
+ default:
69
+ return assertNever(command);
70
+ }
71
+ }
72
+ // The branch a policy branch-pattern constraint is evaluated against: the new branch for a create,
73
+ // otherwise the branch HEAD points at (undefined when detached).
74
+ function targetBranchName(command, snapshot) {
75
+ if (command.kind === "branch-create" || command.kind === "branch-switch") {
76
+ // For create, the new branch; for switch, the branch HEAD will point at after the switch — both
77
+ // are what a branch-pattern policy constraint must be evaluated against.
78
+ return command.branchName;
79
+ }
80
+ return snapshot.currentBranchName;
81
+ }
82
+ // ─── Phase 3: content-free preview ─────────────────────────────────────────────────────────────
83
+ function buildPreview(command, snapshot) {
84
+ const affected = targetBranchName(command, snapshot);
85
+ const base = {
86
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
87
+ wouldCreateRemoteBranch: false, // local kernel never touches a remote
88
+ wouldTriggerChecks: false,
89
+ };
90
+ const fileCount = previewFileCount(command, snapshot);
91
+ return {
92
+ ...base,
93
+ ...(affected !== undefined ? { affectedBranchName: affected } : {}),
94
+ ...(fileCount !== undefined ? { estimatedFileCount: fileCount } : {}),
95
+ };
96
+ }
97
+ function previewFileCount(command, snapshot) {
98
+ switch (command.kind) {
99
+ case "stage":
100
+ case "unstage":
101
+ return command.pathspecs.length;
102
+ case "commit":
103
+ return snapshot.stagedFileCount;
104
+ case "recovery":
105
+ return command.affectedPathspecs.length;
106
+ case "branch-create":
107
+ case "branch-switch":
108
+ case "abort":
109
+ return undefined;
110
+ default:
111
+ return assertNever(command);
112
+ }
113
+ }
114
+ function approvalIsValid(approval, now) {
115
+ if (!approval.required) {
116
+ return "absent";
117
+ }
118
+ if (approval.expiresAtMs !== undefined && approval.expiresAtMs <= now) {
119
+ return "expired";
120
+ }
121
+ return "valid";
122
+ }
123
+ function resolveApprovalGate(approvers, approval, now) {
124
+ const state = approvalIsValid(approval, now);
125
+ if (state === "valid") {
126
+ return { proceed: true };
127
+ }
128
+ if (state === "expired") {
129
+ return { proceed: false, status: "policy-block", blockReason: "approval-expired" };
130
+ }
131
+ return { proceed: false, status: "approval-required", requiredApprovers: approvers };
132
+ }
133
+ // Maps an unsatisfied constraint to its typed block reason. A satisfied constraint returns undefined.
134
+ function constraintBlockReason(constraint, inputs, target, capabilities) {
135
+ if (constraint.kind === "branch-pattern") {
136
+ const matches = target !== undefined && gitDeliveryBranchNameMatchesAny(target, constraint.patterns);
137
+ return matches ? undefined : "policy-pack-blocked";
138
+ }
139
+ if (constraint.kind === "provider-capability") {
140
+ return capabilities.includes(constraint.capability) ? undefined : "provider-capability-absent";
141
+ }
142
+ // risk-class-ceiling
143
+ return gitDeliveryRiskClassWithinCeiling(inputs.kind, constraint.maxRiskClass)
144
+ ? undefined
145
+ : "risk-class-ceiling";
146
+ }
147
+ function resolveConstrainedGate(constraints, inputs, target, capabilities) {
148
+ for (const constraint of constraints) {
149
+ const reason = constraintBlockReason(constraint, inputs, target, capabilities);
150
+ if (reason !== undefined) {
151
+ return { proceed: false, status: "policy-block", blockReason: reason };
152
+ }
153
+ }
154
+ return { proceed: true };
155
+ }
156
+ function resolvePolicyGate(decision, inputs, request, target, capabilities, now) {
157
+ if (decision.outcome === "allowed") {
158
+ return { proceed: true };
159
+ }
160
+ if (decision.outcome === "blocked") {
161
+ return { proceed: false, status: "policy-block", blockReason: decision.reason };
162
+ }
163
+ if (decision.outcome === "approval-gated") {
164
+ return resolveApprovalGate(decision.requiredApprovers, request.approval, now);
165
+ }
166
+ return resolveConstrainedGate(decision.constraints, inputs, target, capabilities);
167
+ }
168
+ // ─── Phase 5: execution dispatch ─────────────────────────────────────────────────────────────
169
+ function dispatchExecution(command, adapter) {
170
+ switch (command.kind) {
171
+ case "branch-create":
172
+ return adapter.createBranch({
173
+ branchName: command.branchName,
174
+ startPointRefHash: command.startPointRefHash,
175
+ });
176
+ case "branch-switch":
177
+ return adapter.switchBranch({ branchName: command.branchName });
178
+ case "stage":
179
+ return adapter.stage({ pathspecs: command.pathspecs });
180
+ case "unstage":
181
+ return adapter.unstage({ pathspecs: command.pathspecs });
182
+ case "commit":
183
+ return adapter.commit({ message: command.message, allowEmpty: command.allowEmpty });
184
+ case "abort":
185
+ return adapter.abort({ operationToAbort: command.operationToAbort });
186
+ case "recovery":
187
+ return adapter.recover({
188
+ recoveryStrategyHint: command.recoveryStrategyHint,
189
+ targetRefHash: command.targetRefHash,
190
+ pathspecs: command.affectedPathspecs,
191
+ });
192
+ default:
193
+ return assertNever(command);
194
+ }
195
+ }
196
+ function outcomeForExecution(result) {
197
+ if (result.outcome === "succeeded") {
198
+ return { status: "succeeded", executionResult: result };
199
+ }
200
+ const category = gitMutationCategoryForExecutionResult(result) ?? "execution-failure";
201
+ if (category === "recovery-required") {
202
+ return { status: "recovery-required", category, executionResult: result };
203
+ }
204
+ if (category === "preflight-block" || category === "policy-block") {
205
+ // Execution results never carry these categories; classify conservatively rather than widen the
206
+ // failed-outcome union.
207
+ return { status: "failed", category: "execution-failure", executionResult: result };
208
+ }
209
+ return { status: "failed", category, executionResult: result };
210
+ }
211
+ // ─── Envelope assembly ─────────────────────────────────────────────────────────────────────────
212
+ function assembleEnvelope(actionId, inputs, policyDecision, approval, preview, executionResult) {
213
+ // kind === inputs.kind holds by construction, so the literal is a sound member of the envelope
214
+ // union; the single cast mirrors the contract module's own validated-construction idiom.
215
+ return {
216
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
217
+ actionId,
218
+ kind: inputs.kind,
219
+ resolvedInputs: inputs,
220
+ policyDecision,
221
+ approvalRequirement: approval,
222
+ preview,
223
+ ...(executionResult !== undefined ? { executionResult } : {}),
224
+ };
225
+ }
226
+ function prepareLifecycle(request, deps) {
227
+ const { command } = request;
228
+ const { snapshot } = deps;
229
+ const inputs = deriveResolvedInputs(command, snapshot);
230
+ const target = targetBranchName(command, snapshot);
231
+ const capabilities = deps.activeProviderCapabilities ?? [];
232
+ const policyContext = {
233
+ actionKind: inputs.kind,
234
+ activeProviderCapabilities: capabilities,
235
+ ...(target !== undefined ? { targetBranchName: target } : {}),
236
+ };
237
+ return {
238
+ request,
239
+ inputs,
240
+ preflight: evaluateGitPreflight(inputs, snapshot),
241
+ preview: buildPreview(command, snapshot),
242
+ target,
243
+ capabilities,
244
+ policyDecision: evaluateGitPolicy(deps.orgPolicyPack, deps.repoPolicyPack, policyContext),
245
+ actionId: deps.newActionId(),
246
+ };
247
+ }
248
+ function buildLifecycleResult(prep, outcome, phaseReached, executionResult) {
249
+ return {
250
+ envelope: assembleEnvelope(prep.actionId, prep.inputs, prep.policyDecision, prep.request.approval, prep.preview, executionResult),
251
+ outcome,
252
+ phaseReached,
253
+ preflight: prep.preflight,
254
+ };
255
+ }
256
+ export async function runGitMutation(request, deps) {
257
+ const journaled = lookupJournal(request, deps);
258
+ if (journaled !== undefined) {
259
+ return journaled;
260
+ }
261
+ const prep = prepareLifecycle(request, deps);
262
+ // Enforcement gate 1: a blocking preflight finding halts before policy enforcement and execution.
263
+ if (!prep.preflight.ok) {
264
+ return buildLifecycleResult(prep, { status: "blocked", category: "preflight-block", findings: prep.preflight.blocking }, "preflight");
265
+ }
266
+ // Enforcement gate 2: policy must permit (and any required approval must be satisfied).
267
+ const gate = resolvePolicyGate(prep.policyDecision, prep.inputs, request, prep.target, prep.capabilities, deps.now());
268
+ if (!gate.proceed) {
269
+ const outcome = gate.status === "approval-required"
270
+ ? { status: "approval-required", requiredApprovers: gate.requiredApprovers }
271
+ : { status: "blocked", category: "policy-block", blockReason: gate.blockReason };
272
+ return buildLifecycleResult(prep, outcome, "policy");
273
+ }
274
+ // Execute through the narrow adapter and emit the structured result.
275
+ const executionResult = await runAdapter(request.command, deps.adapter);
276
+ const result = buildLifecycleResult(prep, outcomeForExecution(executionResult), "result", executionResult);
277
+ recordJournal(request, deps, result);
278
+ return result;
279
+ }
280
+ function lookupJournal(request, deps) {
281
+ if (request.idempotencyKey === undefined || deps.journal === undefined) {
282
+ return undefined;
283
+ }
284
+ return deps.journal.lookup(request.idempotencyKey);
285
+ }
286
+ function recordJournal(request, deps, result) {
287
+ if (request.idempotencyKey !== undefined &&
288
+ deps.journal !== undefined &&
289
+ result.outcome.status === "succeeded") {
290
+ deps.journal.record(request.idempotencyKey, result);
291
+ }
292
+ }
293
+ // Adapter calls are isolated so a thrown/rejected adapter (timeout, denial, internal fault) becomes
294
+ // a structured internal-error result rather than propagating out of the orchestrator.
295
+ async function runAdapter(command, adapter) {
296
+ try {
297
+ return await dispatchExecution(command, adapter);
298
+ }
299
+ catch {
300
+ return {
301
+ schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
302
+ outcome: "failed",
303
+ durationMs: 0,
304
+ errorCode: "internal-error",
305
+ };
306
+ }
307
+ }
308
+ function assertNever(command) {
309
+ throw new Error(`unhandled git mutation command: ${JSON.stringify(command)}`);
310
+ }
311
+ // In-memory idempotency journal. Suitable for a single orchestration host; a durable journal (the
312
+ // evidence ledger, #474) implements the same port over persistent storage.
313
+ export function createInMemoryGitMutationJournal() {
314
+ const store = new Map();
315
+ return {
316
+ lookup: (key) => store.get(key),
317
+ record: (key, result) => {
318
+ store.set(key, result);
319
+ },
320
+ };
321
+ }
@@ -0,0 +1,35 @@
1
+ import type { GitDeliveryAbortableOperation, GitDeliveryResolvedInputs } from "@oscharko-dev/keiko-contracts";
2
+ export interface GitWorktreeSnapshot {
3
+ readonly headDetached: boolean;
4
+ readonly currentBranchName?: string | undefined;
5
+ readonly stagedFileCount: number;
6
+ readonly unstagedFileCount: number;
7
+ readonly untrackedFileCount: number;
8
+ readonly hasUpstream: boolean;
9
+ readonly aheadCount: number;
10
+ readonly behindCount: number;
11
+ readonly existingLocalBranchNames: readonly string[];
12
+ readonly remoteAliases: readonly string[];
13
+ readonly remoteReachable?: boolean | undefined;
14
+ readonly operationInProgress?: GitDeliveryAbortableOperation | undefined;
15
+ }
16
+ export type GitPreflightFindingCode = "detached-head" | "branch-already-exists" | "base-branch-missing" | "switch-target-missing" | "no-changes-to-stage" | "nothing-staged-to-unstage" | "nothing-staged-to-commit" | "untracked-files-impacted" | "no-upstream-configured" | "nothing-to-push" | "non-fast-forward" | "remote-alias-missing" | "remote-unreachable" | "operation-in-progress" | "no-operation-to-abort" | "recovery-target-unset" | "dirty-worktree-impacts-recovery";
17
+ export declare const GIT_PREFLIGHT_FINDING_CODES: readonly GitPreflightFindingCode[];
18
+ export type GitPreflightSeverity = "blocking" | "advisory";
19
+ export type GitPreflightRemediation = "user-actionable" | "internal";
20
+ export declare function gitPreflightRemediationFor(code: GitPreflightFindingCode): GitPreflightRemediation;
21
+ export interface GitPreflightFinding {
22
+ readonly code: GitPreflightFindingCode;
23
+ readonly severity: GitPreflightSeverity;
24
+ readonly remediation: GitPreflightRemediation;
25
+ readonly phase: "preflight";
26
+ }
27
+ export interface GitPreflightReport {
28
+ readonly ok: boolean;
29
+ readonly findings: readonly GitPreflightFinding[];
30
+ readonly blocking: readonly GitPreflightFinding[];
31
+ readonly advisory: readonly GitPreflightFinding[];
32
+ }
33
+ export declare function evaluateGitPreflight(inputs: GitDeliveryResolvedInputs, snapshot: GitWorktreeSnapshot): GitPreflightReport;
34
+ export declare function isGitPreflightFindingCode(value: unknown): value is GitPreflightFindingCode;
35
+ //# sourceMappingURL=git-mutation-preflight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-mutation-preflight.d.ts","sourceRoot":"","sources":["../src/git-mutation-preflight.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EACV,6BAA6B,EAE7B,yBAAyB,EAC1B,MAAM,+BAA+B,CAAC;AAOvC,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAE/B,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IAEpC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAG9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,wBAAwB,EAAE,SAAS,MAAM,EAAE,CAAC;IACrD,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAG1C,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAE/C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,6BAA6B,GAAG,SAAS,CAAC;CAC1E;AAID,MAAM,MAAM,uBAAuB,GAC/B,eAAe,GACf,uBAAuB,GACvB,qBAAqB,GACrB,uBAAuB,GACvB,qBAAqB,GACrB,2BAA2B,GAC3B,0BAA0B,GAC1B,0BAA0B,GAC1B,wBAAwB,GACxB,iBAAiB,GACjB,kBAAkB,GAClB,sBAAsB,GACtB,oBAAoB,GACpB,uBAAuB,GACvB,uBAAuB,GACvB,uBAAuB,GACvB,iCAAiC,CAAC;AAEtC,eAAO,MAAM,2BAA2B,EAAE,SAAS,uBAAuB,EAkBhE,CAAC;AAIX,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG,UAAU,CAAC;AAK3D,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,GAAG,UAAU,CAAC;AAwBrE,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,uBAAuB,GAAG,uBAAuB,CAEjG;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;IACvC,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC;IACxC,QAAQ,CAAC,WAAW,EAAE,uBAAuB,CAAC;IAC9C,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IAEjC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAClD,QAAQ,CAAC,QAAQ,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAClD,QAAQ,CAAC,QAAQ,EAAE,SAAS,mBAAmB,EAAE,CAAC;CACnD;AAuND,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,yBAAyB,EACjC,QAAQ,EAAE,mBAAmB,GAC5B,kBAAkB,CAKpB;AAID,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,uBAAuB,CAI1F"}
@@ -0,0 +1,226 @@
1
+ // Deterministic preflight evaluation for governed Git writes (Issue #472, Epic #470).
2
+ //
3
+ // Preflight is a PURE function over a content-free repository snapshot. Same (resolvedInputs,
4
+ // snapshot) pair always yields the same report, so preflight reruns are idempotent by construction
5
+ // (the issue's "safe retry and idempotency for preflight reruns" requirement). Each finding carries
6
+ // a typed `code`, a contextual `severity` (blocking vs advisory), and an intrinsic `remediation`
7
+ // (user-actionable vs internal). That remediation split is the AC2 distinction: callers can tell an
8
+ // actionable user fix ("stage a file", "configure an upstream") from an internal construction fault
9
+ // without parsing any message string.
10
+ //
11
+ // The snapshot is produced by a read-only inspection port (the Node reader reuses the existing
12
+ // read-only git inspection allowlist — `git status / rev-parse / branch / remote`). Preflight never
13
+ // runs git itself; it only reasons over the snapshot. No IO, no clock, no randomness.
14
+ export const GIT_PREFLIGHT_FINDING_CODES = [
15
+ "detached-head",
16
+ "branch-already-exists",
17
+ "base-branch-missing",
18
+ "switch-target-missing",
19
+ "no-changes-to-stage",
20
+ "nothing-staged-to-unstage",
21
+ "nothing-staged-to-commit",
22
+ "untracked-files-impacted",
23
+ "no-upstream-configured",
24
+ "nothing-to-push",
25
+ "non-fast-forward",
26
+ "remote-alias-missing",
27
+ "remote-unreachable",
28
+ "operation-in-progress",
29
+ "no-operation-to-abort",
30
+ "recovery-target-unset",
31
+ "dirty-worktree-impacts-recovery",
32
+ ];
33
+ // Frozen, exhaustive code → remediation table. Keyed on every code so a new code forces an explicit
34
+ // classification here (the Record is not Partial).
35
+ const FINDING_REMEDIATION = {
36
+ "detached-head": "user-actionable",
37
+ "branch-already-exists": "user-actionable",
38
+ "base-branch-missing": "user-actionable",
39
+ "switch-target-missing": "user-actionable",
40
+ "no-changes-to-stage": "user-actionable",
41
+ "nothing-staged-to-unstage": "user-actionable",
42
+ "nothing-staged-to-commit": "user-actionable",
43
+ "untracked-files-impacted": "user-actionable",
44
+ "no-upstream-configured": "user-actionable",
45
+ "nothing-to-push": "user-actionable",
46
+ "non-fast-forward": "user-actionable",
47
+ "remote-alias-missing": "user-actionable",
48
+ "remote-unreachable": "user-actionable",
49
+ "operation-in-progress": "user-actionable",
50
+ "no-operation-to-abort": "user-actionable",
51
+ "recovery-target-unset": "internal",
52
+ "dirty-worktree-impacts-recovery": "user-actionable",
53
+ };
54
+ export function gitPreflightRemediationFor(code) {
55
+ return FINDING_REMEDIATION[code];
56
+ }
57
+ // ─── Finding builders ───────────────────────────────────────────────────────────────────────
58
+ function blocking(code) {
59
+ return {
60
+ code,
61
+ severity: "blocking",
62
+ remediation: gitPreflightRemediationFor(code),
63
+ phase: "preflight",
64
+ };
65
+ }
66
+ function advisory(code) {
67
+ return {
68
+ code,
69
+ severity: "advisory",
70
+ remediation: gitPreflightRemediationFor(code),
71
+ phase: "preflight",
72
+ };
73
+ }
74
+ function report(findings) {
75
+ const blockingFindings = findings.filter((f) => f.severity === "blocking");
76
+ const advisoryFindings = findings.filter((f) => f.severity === "advisory");
77
+ return {
78
+ ok: blockingFindings.length === 0,
79
+ findings,
80
+ blocking: blockingFindings,
81
+ advisory: advisoryFindings,
82
+ };
83
+ }
84
+ function branchExists(snapshot, name) {
85
+ return snapshot.existingLocalBranchNames.includes(name) || snapshot.currentBranchName === name;
86
+ }
87
+ // ─── Per-kind evaluators ──────────────────────────────────────────────────────────────────
88
+ // Each evaluator is a pure function of (typed inputs, snapshot). Findings are emitted in a stable,
89
+ // deterministic order so repeated runs produce byte-identical reports.
90
+ function preflightBranchCreate(inputs, snapshot) {
91
+ const findings = [];
92
+ if (branchExists(snapshot, inputs.branchName)) {
93
+ findings.push(blocking("branch-already-exists"));
94
+ }
95
+ if (!branchExists(snapshot, inputs.baseBranchName)) {
96
+ findings.push(blocking("base-branch-missing"));
97
+ }
98
+ if (snapshot.headDetached) {
99
+ findings.push(advisory("detached-head"));
100
+ }
101
+ return findings;
102
+ }
103
+ function preflightBranchSwitch(inputs, snapshot) {
104
+ const findings = [];
105
+ // The switch target must be an existing local branch (the governed flow switches between known
106
+ // branches; creating-then-switching is a branch-create followed by a branch-switch). The branch the
107
+ // worktree is already on is trivially "existing", so a redundant switch is not blocked.
108
+ if (!branchExists(snapshot, inputs.branchName)) {
109
+ findings.push(blocking("switch-target-missing"));
110
+ }
111
+ if (snapshot.operationInProgress !== undefined) {
112
+ // Switching mid-merge/rebase abandons the sequencing operation's context; surface without halting.
113
+ findings.push(advisory("operation-in-progress"));
114
+ }
115
+ return findings;
116
+ }
117
+ function preflightStage(inputs, snapshot) {
118
+ const findings = [];
119
+ if (inputs.pathCount === 0) {
120
+ findings.push(blocking("no-changes-to-stage"));
121
+ }
122
+ if (inputs.includesUntracked && snapshot.untrackedFileCount > 0) {
123
+ findings.push(advisory("untracked-files-impacted"));
124
+ }
125
+ if (snapshot.operationInProgress !== undefined) {
126
+ findings.push(advisory("operation-in-progress"));
127
+ }
128
+ return findings;
129
+ }
130
+ function preflightUnstage(inputs, snapshot) {
131
+ if (inputs.pathCount === 0 || snapshot.stagedFileCount === 0) {
132
+ return [blocking("nothing-staged-to-unstage")];
133
+ }
134
+ return [];
135
+ }
136
+ function preflightCommit(inputs, snapshot) {
137
+ const findings = [];
138
+ if (snapshot.stagedFileCount === 0 && !inputs.allowEmptyCommit) {
139
+ findings.push(blocking("nothing-staged-to-commit"));
140
+ }
141
+ if (snapshot.headDetached) {
142
+ // A commit on a detached HEAD produces an unreferenced commit; the governed flow requires a
143
+ // branch so the result is reachable and auditable.
144
+ findings.push(blocking("detached-head"));
145
+ }
146
+ if (snapshot.operationInProgress !== undefined) {
147
+ // Concluding a merge/rebase via commit is legitimate; surface it without halting.
148
+ findings.push(advisory("operation-in-progress"));
149
+ }
150
+ return findings;
151
+ }
152
+ function preflightPush(inputs, snapshot) {
153
+ const findings = [];
154
+ if (!snapshot.remoteAliases.includes(inputs.remoteAlias)) {
155
+ findings.push(blocking("remote-alias-missing"));
156
+ }
157
+ if (!snapshot.hasUpstream && !inputs.setUpstreamTracking) {
158
+ findings.push(blocking("no-upstream-configured"));
159
+ }
160
+ if (snapshot.remoteReachable === false) {
161
+ findings.push(blocking("remote-unreachable"));
162
+ }
163
+ if (snapshot.behindCount > 0 && !inputs.forcePush) {
164
+ // The local branch is behind its upstream: a normal (non-force) push cannot fast-forward and the
165
+ // remote will reject it. Detected here from the tracking-ref distance (best-effort, no network
166
+ // probe); the authoritative signal is the execution-time rejection classification in the publish
167
+ // gateway. Blocking so an obviously-doomed push is stopped before it reaches the remote.
168
+ findings.push(blocking("non-fast-forward"));
169
+ }
170
+ if (snapshot.aheadCount === 0 && snapshot.behindCount === 0 && !inputs.forcePush) {
171
+ findings.push(advisory("nothing-to-push"));
172
+ }
173
+ return findings;
174
+ }
175
+ function preflightAbort(inputs, snapshot) {
176
+ if (snapshot.operationInProgress !== inputs.operationToAbort) {
177
+ // Either nothing is in progress, or a different operation is — both mean the requested abort has
178
+ // no matching operation to act on.
179
+ return [blocking("no-operation-to-abort")];
180
+ }
181
+ return [];
182
+ }
183
+ function preflightRecovery(inputs, snapshot) {
184
+ const findings = [];
185
+ if (inputs.targetRefHash.length === 0) {
186
+ findings.push(blocking("recovery-target-unset"));
187
+ }
188
+ const dirty = snapshot.unstagedFileCount > 0 || snapshot.untrackedFileCount > 0;
189
+ const discardsWorktree = inputs.recoveryStrategyHint === "mixed-reset" || inputs.recoveryStrategyHint === "soft-reset";
190
+ if (dirty && discardsWorktree) {
191
+ // A reset that does not stash first leaves (soft) or touches (mixed) local changes; surface the
192
+ // interaction so the operator can choose stash-and-reset instead.
193
+ findings.push(advisory("dirty-worktree-impacts-recovery"));
194
+ }
195
+ return findings;
196
+ }
197
+ // Provider actions (pr-create / pr-update / merge) have no snapshot-derivable local precondition:
198
+ // their readiness (merge readiness, required checks, approvals) lives in the provider-neutral
199
+ // contract and is evaluated by the provider gateway in later slices (#477/#478), not by local
200
+ // worktree preflight. They return a uniform empty (ok) report so the lifecycle shape is identical.
201
+ function preflightNoLocalPrecondition() {
202
+ return [];
203
+ }
204
+ const PREFLIGHT_DISPATCH = {
205
+ "branch-create": preflightBranchCreate,
206
+ "branch-switch": preflightBranchSwitch,
207
+ stage: preflightStage,
208
+ unstage: preflightUnstage,
209
+ commit: preflightCommit,
210
+ push: preflightPush,
211
+ abort: preflightAbort,
212
+ recovery: preflightRecovery,
213
+ "pr-create": preflightNoLocalPrecondition,
214
+ "pr-update": preflightNoLocalPrecondition,
215
+ merge: preflightNoLocalPrecondition,
216
+ };
217
+ export function evaluateGitPreflight(inputs, snapshot) {
218
+ // The lookup is keyed by inputs.kind, so the selected evaluator's parameter type matches inputs by
219
+ // construction; the cast bridges the union-to-member variance the indexed access cannot prove.
220
+ const evaluate = PREFLIGHT_DISPATCH[inputs.kind];
221
+ return report(evaluate(inputs, snapshot));
222
+ }
223
+ // ─── Guards ─────────────────────────────────────────────────────────────────────────────────
224
+ export function isGitPreflightFindingCode(value) {
225
+ return (typeof value === "string" && GIT_PREFLIGHT_FINDING_CODES.includes(value));
226
+ }
@@ -0,0 +1,15 @@
1
+ import type { GitDeliveryExecutionErrorCode, GitDeliveryExecutionResult } from "@oscharko-dev/keiko-contracts";
2
+ export type GitMutationLifecyclePhase = "resolve" | "preflight" | "preview" | "policy" | "execute" | "result";
3
+ export declare const GIT_MUTATION_LIFECYCLE_PHASES: readonly GitMutationLifecyclePhase[];
4
+ export declare const GIT_MUTATION_PHASE_ORDER: Readonly<Record<GitMutationLifecyclePhase, number>>;
5
+ export type GitMutationFailureCategory = "policy-block" | "preflight-block" | "execution-failure" | "provider-failure" | "recovery-required";
6
+ export declare const GIT_MUTATION_FAILURE_CATEGORIES: readonly GitMutationFailureCategory[];
7
+ export type GitMutationStatus = "succeeded" | "approval-required" | "blocked" | "failed" | "recovery-required";
8
+ export declare const GIT_MUTATION_STATUSES: readonly GitMutationStatus[];
9
+ export declare function gitMutationCategoryForExecutionError(code: GitDeliveryExecutionErrorCode): GitMutationFailureCategory;
10
+ export declare function gitMutationCategoryForExecutionResult(result: GitDeliveryExecutionResult): GitMutationFailureCategory | undefined;
11
+ export declare function gitMutationFailureIsRecoverable(category: GitMutationFailureCategory): boolean;
12
+ export declare function isGitMutationLifecyclePhase(value: unknown): value is GitMutationLifecyclePhase;
13
+ export declare function isGitMutationFailureCategory(value: unknown): value is GitMutationFailureCategory;
14
+ export declare function isGitMutationStatus(value: unknown): value is GitMutationStatus;
15
+ //# sourceMappingURL=git-mutation-taxonomy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-mutation-taxonomy.d.ts","sourceRoot":"","sources":["../src/git-mutation-taxonomy.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EACV,6BAA6B,EAC7B,0BAA0B,EAC3B,MAAM,+BAA+B,CAAC;AAQvC,MAAM,MAAM,yBAAyB,GACjC,SAAS,GACT,WAAW,GACX,SAAS,GACT,QAAQ,GACR,SAAS,GACT,QAAQ,CAAC;AAEb,eAAO,MAAM,6BAA6B,EAAE,SAAS,yBAAyB,EAOpE,CAAC;AAKX,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAAC,MAAM,CAAC,yBAAyB,EAAE,MAAM,CAAC,CAO/E,CAAC;AAaX,MAAM,MAAM,0BAA0B,GAClC,cAAc,GACd,iBAAiB,GACjB,mBAAmB,GACnB,kBAAkB,GAClB,mBAAmB,CAAC;AAExB,eAAO,MAAM,+BAA+B,EAAE,SAAS,0BAA0B,EAMvE,CAAC;AAQX,MAAM,MAAM,iBAAiB,GACzB,WAAW,GACX,mBAAmB,GACnB,SAAS,GACT,QAAQ,GACR,mBAAmB,CAAC;AAExB,eAAO,MAAM,qBAAqB,EAAE,SAAS,iBAAiB,EAMpD,CAAC;AAuBX,wBAAgB,oCAAoC,CAClD,IAAI,EAAE,6BAA6B,GAClC,0BAA0B,CAE5B;AAOD,wBAAgB,qCAAqC,CACnD,MAAM,EAAE,0BAA0B,GACjC,0BAA0B,GAAG,SAAS,CAUxC;AAID,wBAAgB,+BAA+B,CAAC,QAAQ,EAAE,0BAA0B,GAAG,OAAO,CAE7F;AAID,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,yBAAyB,CAK9F;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,0BAA0B,CAKhG;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAE9E"}