@juicesharp/rpiv-workflow 1.14.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/LICENSE +21 -0
- package/README.md +449 -0
- package/api.ts +557 -0
- package/audit.ts +217 -0
- package/built-ins.ts +65 -0
- package/command.ts +137 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +120 -0
- package/docs/workflow-authoring.md +629 -0
- package/docs/workflow-basics.md +122 -0
- package/docs-protocol.ts +106 -0
- package/fanout.ts +96 -0
- package/host.ts +97 -0
- package/index.ts +230 -0
- package/internal-utils.ts +69 -0
- package/internal.ts +27 -0
- package/layers.ts +33 -0
- package/lifecycle.ts +274 -0
- package/load/cache.test.ts +82 -0
- package/load/cache.ts +40 -0
- package/load/index.ts +159 -0
- package/load/merge.ts +136 -0
- package/load/normalize.ts +73 -0
- package/load/paths.ts +32 -0
- package/load/resolve-default.ts +43 -0
- package/load/shape-guards.test.ts +74 -0
- package/load/shape-guards.ts +42 -0
- package/messages.ts +185 -0
- package/outcomes/collectors/directory-path.test.ts +64 -0
- package/outcomes/collectors/directory-path.ts +40 -0
- package/outcomes/collectors/index.ts +21 -0
- package/outcomes/collectors/tool-call.test.ts +110 -0
- package/outcomes/collectors/tool-call.ts +63 -0
- package/outcomes/collectors/transcript-path.test.ts +70 -0
- package/outcomes/collectors/transcript-path.ts +53 -0
- package/outcomes/collectors/union.test.ts +59 -0
- package/outcomes/collectors/union.ts +55 -0
- package/outcomes/collectors/url.test.ts +67 -0
- package/outcomes/collectors/url.ts +45 -0
- package/outcomes/collectors/workspace-diff.test.ts +107 -0
- package/outcomes/collectors/workspace-diff.ts +123 -0
- package/outcomes/git-commit.test.ts +194 -0
- package/outcomes/git-commit.ts +192 -0
- package/outcomes/index.ts +22 -0
- package/outcomes/parsers/index.ts +11 -0
- package/outcomes/parsers/json-body.test.ts +80 -0
- package/outcomes/parsers/json-body.ts +50 -0
- package/outcomes/side-effect.ts +26 -0
- package/output-spec.ts +170 -0
- package/output.ts +98 -0
- package/package.json +83 -0
- package/preview.ts +120 -0
- package/routing.ts +79 -0
- package/runner/chain-advance.ts +185 -0
- package/runner/index.ts +7 -0
- package/runner/runner.ts +356 -0
- package/runner/script-stage.ts +240 -0
- package/runner/stage-lifecycle.ts +447 -0
- package/sessions/extraction.ts +297 -0
- package/sessions/index.ts +7 -0
- package/sessions/sessions.ts +269 -0
- package/sessions/spawn.ts +135 -0
- package/state/index.ts +27 -0
- package/state/paths.ts +46 -0
- package/state/reads.ts +190 -0
- package/state/state.ts +115 -0
- package/state/writes.ts +58 -0
- package/transcript.ts +156 -0
- package/triggers.ts +27 -0
- package/typebox-adapter.ts +48 -0
- package/types.ts +237 -0
- package/validate-output.ts +120 -0
- package/validate-workflow.ts +491 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type { CollectCtx, SnapshotCtx } from "../../output-spec.js";
|
|
7
|
+
import { type WorkspaceDiffSnapshot, workspaceDiffCollector } from "./workspace-diff.js";
|
|
8
|
+
|
|
9
|
+
const hasGit = (() => {
|
|
10
|
+
try {
|
|
11
|
+
execSync("git --version", { stdio: "ignore" });
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
const initRepo = (cwd: string): void => {
|
|
19
|
+
execSync("git init -q", { cwd });
|
|
20
|
+
execSync("git config user.email test@example.com", { cwd });
|
|
21
|
+
execSync("git config user.name Test", { cwd });
|
|
22
|
+
execSync("git commit --allow-empty -q -m initial", { cwd });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const snapshotCtxOf = (cwd: string): SnapshotCtx => ({
|
|
26
|
+
cwd,
|
|
27
|
+
runId: "test",
|
|
28
|
+
stageIndex: 0,
|
|
29
|
+
state: {} as never,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const collectCtxOf = (
|
|
33
|
+
cwd: string,
|
|
34
|
+
snapshot: WorkspaceDiffSnapshot | undefined,
|
|
35
|
+
): CollectCtx<WorkspaceDiffSnapshot | undefined> => ({
|
|
36
|
+
...snapshotCtxOf(cwd),
|
|
37
|
+
branch: [],
|
|
38
|
+
branchOffset: undefined,
|
|
39
|
+
snapshot,
|
|
40
|
+
skill: "test",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe.runIf(hasGit)("workspaceDiffCollector", () => {
|
|
44
|
+
let tmpDir: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
tmpDir = mkdtempSync(join(tmpdir(), "rpiv-wd-"));
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("emits one fs artifact per file written during the stage", async () => {
|
|
54
|
+
initRepo(tmpDir);
|
|
55
|
+
const collector = workspaceDiffCollector();
|
|
56
|
+
const snapshot = await collector.snapshot?.(snapshotCtxOf(tmpDir));
|
|
57
|
+
// Write two files post-snapshot.
|
|
58
|
+
writeFileSync(join(tmpDir, "a.txt"), "hello");
|
|
59
|
+
writeFileSync(join(tmpDir, "b.txt"), "world");
|
|
60
|
+
|
|
61
|
+
const result = await collector.collect(collectCtxOf(tmpDir, snapshot));
|
|
62
|
+
expect(result.kind).toBe("ok");
|
|
63
|
+
if (result.kind !== "ok") return;
|
|
64
|
+
const paths = result.artifacts.map((a) => (a.handle.kind === "fs" ? a.handle.path : "")).sort();
|
|
65
|
+
expect(paths).toEqual(["a.txt", "b.txt"]);
|
|
66
|
+
// Every artifact's role and meta hint come from the collector, not the user.
|
|
67
|
+
expect(result.artifacts[0]?.role).toBe("changed");
|
|
68
|
+
expect(result.artifacts[0]?.meta?.gitStatus).toBe("??");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("skips files whose status was unchanged from snapshot (untouched files don't count)", async () => {
|
|
72
|
+
initRepo(tmpDir);
|
|
73
|
+
// Pre-snapshot file — already untracked.
|
|
74
|
+
writeFileSync(join(tmpDir, "preexisting.txt"), "x");
|
|
75
|
+
const collector = workspaceDiffCollector();
|
|
76
|
+
const snapshot = await collector.snapshot?.(snapshotCtxOf(tmpDir));
|
|
77
|
+
|
|
78
|
+
// Write a NEW file during the "stage."
|
|
79
|
+
writeFileSync(join(tmpDir, "new.txt"), "y");
|
|
80
|
+
|
|
81
|
+
const result = await collector.collect(collectCtxOf(tmpDir, snapshot));
|
|
82
|
+
expect(
|
|
83
|
+
result.kind === "ok" && result.artifacts.map((a) => (a.handle.kind === "fs" ? a.handle.path : "")),
|
|
84
|
+
).toEqual(["new.txt"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("applies the optional filter", async () => {
|
|
88
|
+
initRepo(tmpDir);
|
|
89
|
+
const collector = workspaceDiffCollector({ filter: (p) => p.endsWith(".md") });
|
|
90
|
+
const snapshot = await collector.snapshot?.(snapshotCtxOf(tmpDir));
|
|
91
|
+
writeFileSync(join(tmpDir, "a.txt"), "x");
|
|
92
|
+
writeFileSync(join(tmpDir, "b.md"), "y");
|
|
93
|
+
|
|
94
|
+
const result = await collector.collect(collectCtxOf(tmpDir, snapshot));
|
|
95
|
+
expect(
|
|
96
|
+
result.kind === "ok" && result.artifacts.map((a) => (a.handle.kind === "fs" ? a.handle.path : "")),
|
|
97
|
+
).toEqual(["b.md"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("fail-soft: cwd is not a git repo → snapshot undefined → collect returns empty", async () => {
|
|
101
|
+
const collector = workspaceDiffCollector();
|
|
102
|
+
const snapshot = await collector.snapshot?.(snapshotCtxOf(tmpDir));
|
|
103
|
+
expect(snapshot).toBeUndefined();
|
|
104
|
+
const result = await collector.collect(collectCtxOf(tmpDir, snapshot));
|
|
105
|
+
expect(result.kind === "ok" && result.artifacts).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-diff collector — emits one Artifact per file the stage
|
|
3
|
+
* touched in the working tree.
|
|
4
|
+
*
|
|
5
|
+
* Discovery model: capture `git status --porcelain` pre-stage as
|
|
6
|
+
* snapshot, then take the diff post-stage. Newly-untracked files and
|
|
7
|
+
* files whose status changed both count. Pure git — no transcript
|
|
8
|
+
* scanning, no tool-use observation, no agent narration involved.
|
|
9
|
+
*
|
|
10
|
+
* Fail-soft: cwd is not a git repo OR git isn't on PATH → snapshot is
|
|
11
|
+
* `undefined`, collector returns `ok` with an empty list (the runner's
|
|
12
|
+
* completion-strategy check then decides whether that's a halt). Same
|
|
13
|
+
* posture as `gitCommitCollector`.
|
|
14
|
+
*
|
|
15
|
+
* Optional `filter(path)` narrows the set — useful for "only `.ts`
|
|
16
|
+
* files," "only files under `src/`," etc. Authors who want more
|
|
17
|
+
* structural narrowing (per-file role tags, per-file metadata) write
|
|
18
|
+
* a custom collector — `workspaceDiffCollector` deliberately stays the
|
|
19
|
+
* thin diff primitive.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { type Artifact, fs as fsHandle } from "../../handle.js";
|
|
25
|
+
import type { ArtifactCollector, CollectCtx, SnapshotCtx } from "../../output-spec.js";
|
|
26
|
+
import { defineCollector } from "../../output-spec.js";
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
/** Same budget as gitCommitCollector — generous for local repos, short enough that a hung mount can't pin the stage. */
|
|
31
|
+
const GIT_EXEC_TIMEOUT_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
export interface WorkspaceDiffSnapshot {
|
|
34
|
+
/** Post-stage diff compares against this set of (path, statusCode) pairs captured pre-stage. */
|
|
35
|
+
statusByPath: ReadonlyMap<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkspaceDiffCollectorOpts {
|
|
39
|
+
/**
|
|
40
|
+
* Optional path predicate. Return true to include the file in the
|
|
41
|
+
* collected artifacts, false to drop it. Receives the cwd-relative
|
|
42
|
+
* path that `git status --porcelain` emitted.
|
|
43
|
+
*/
|
|
44
|
+
filter?: (path: string) => boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const workspaceDiffCollector = (
|
|
48
|
+
opts: WorkspaceDiffCollectorOpts = {},
|
|
49
|
+
): ArtifactCollector<WorkspaceDiffSnapshot | undefined> =>
|
|
50
|
+
defineCollector<WorkspaceDiffSnapshot | undefined>({
|
|
51
|
+
snapshot: capturePorcelainSnapshot,
|
|
52
|
+
collect: (ctx) => collectDiffArtifacts(ctx, opts.filter),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Snapshot + diff implementation
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function capturePorcelainSnapshot(ctx: SnapshotCtx): Promise<WorkspaceDiffSnapshot | undefined> {
|
|
60
|
+
const status = await runGitStatus(ctx.cwd);
|
|
61
|
+
if (status === undefined) return undefined;
|
|
62
|
+
return { statusByPath: parsePorcelain(status) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function collectDiffArtifacts(
|
|
66
|
+
ctx: CollectCtx<WorkspaceDiffSnapshot | undefined>,
|
|
67
|
+
filter: ((path: string) => boolean) | undefined,
|
|
68
|
+
): Promise<{ kind: "ok"; artifacts: readonly Artifact[] }> {
|
|
69
|
+
const snapshot = ctx.snapshot;
|
|
70
|
+
if (!snapshot) return { kind: "ok", artifacts: [] };
|
|
71
|
+
|
|
72
|
+
const status = await runGitStatus(ctx.cwd);
|
|
73
|
+
if (status === undefined) return { kind: "ok", artifacts: [] };
|
|
74
|
+
const post = parsePorcelain(status);
|
|
75
|
+
|
|
76
|
+
const artifacts: Artifact[] = [];
|
|
77
|
+
for (const [path, code] of post) {
|
|
78
|
+
// Skip files whose status is unchanged from the snapshot — they
|
|
79
|
+
// weren't touched DURING this stage.
|
|
80
|
+
if (snapshot.statusByPath.get(path) === code) continue;
|
|
81
|
+
if (filter && !filter(path)) continue;
|
|
82
|
+
artifacts.push({ handle: fsHandle(path), role: "changed", meta: { gitStatus: code } });
|
|
83
|
+
}
|
|
84
|
+
return { kind: "ok", artifacts };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runGitStatus(cwd: string): Promise<string | undefined> {
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
90
|
+
cwd,
|
|
91
|
+
encoding: "utf-8",
|
|
92
|
+
timeout: GIT_EXEC_TIMEOUT_MS,
|
|
93
|
+
});
|
|
94
|
+
return stdout;
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse `git status --porcelain` output: each line is `XY <path>` where
|
|
102
|
+
* XY is the two-character status code. We key by path and keep the
|
|
103
|
+
* full XY so post-stage diff sees status transitions (e.g. ` M` → `MM`).
|
|
104
|
+
*
|
|
105
|
+
* Renames (`R old -> new`) are normalised to just the new path —
|
|
106
|
+
* downstream collectors / parsers don't usually care about the prior
|
|
107
|
+
* name and including both halves doubles the artifact count.
|
|
108
|
+
*/
|
|
109
|
+
function parsePorcelain(out: string): Map<string, string> {
|
|
110
|
+
const map = new Map<string, string>();
|
|
111
|
+
for (const line of out.split("\n")) {
|
|
112
|
+
if (line.length < 4) continue;
|
|
113
|
+
const code = line.slice(0, 2);
|
|
114
|
+
let path = line.slice(3).trim();
|
|
115
|
+
// Rename: `R old -> new` — take the new name.
|
|
116
|
+
const arrow = path.indexOf(" -> ");
|
|
117
|
+
if (arrow !== -1) path = path.slice(arrow + 4);
|
|
118
|
+
// Strip wrapping quotes (paths with whitespace).
|
|
119
|
+
if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
|
|
120
|
+
map.set(path, code);
|
|
121
|
+
}
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the git-commit outcome — covers the collector + parser pair
|
|
3
|
+
* on the success path, when git isn't on PATH, and when the working
|
|
4
|
+
* tree isn't a git repo.
|
|
5
|
+
*
|
|
6
|
+
* The outcome is fail-soft by contract: every git error path collapses
|
|
7
|
+
* to a `noOp: true` payload so the workflow keeps moving. These tests
|
|
8
|
+
* pin that contract — a regression that converts a failure into a throw
|
|
9
|
+
* would surface here as an unhandled rejection.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
+
import type { CollectCtx, ParseCtx, SnapshotCtx } from "../output.js";
|
|
18
|
+
import {
|
|
19
|
+
type GitHeadSnapshot,
|
|
20
|
+
gitCommitCollector,
|
|
21
|
+
gitCommitOutcome,
|
|
22
|
+
gitCommitParser,
|
|
23
|
+
gitHeadSnapshot,
|
|
24
|
+
} from "./git-commit.js";
|
|
25
|
+
|
|
26
|
+
const hasGit = (() => {
|
|
27
|
+
try {
|
|
28
|
+
execSync("git --version", { stdio: "ignore" });
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const initRepo = (cwd: string): void => {
|
|
36
|
+
execSync("git init -q", { cwd });
|
|
37
|
+
execSync("git config user.email test@example.com", { cwd });
|
|
38
|
+
execSync("git config user.name Test", { cwd });
|
|
39
|
+
execSync("git commit --allow-empty -q -m initial", { cwd });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const snapshotCtx = (cwd: string): SnapshotCtx => ({
|
|
43
|
+
cwd,
|
|
44
|
+
runId: "test-run",
|
|
45
|
+
stageIndex: 0,
|
|
46
|
+
state: {
|
|
47
|
+
originalInput: "",
|
|
48
|
+
primaryArtifact: undefined,
|
|
49
|
+
output: undefined,
|
|
50
|
+
named: {},
|
|
51
|
+
stagesCompleted: 0,
|
|
52
|
+
lastAllocatedStageNumber: 0,
|
|
53
|
+
telemetry: {
|
|
54
|
+
backwardJumps: 0,
|
|
55
|
+
droppedRoutingRows: [],
|
|
56
|
+
},
|
|
57
|
+
termination: {
|
|
58
|
+
success: false,
|
|
59
|
+
error: undefined,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const collectCtx = (cwd: string, snapshot: GitHeadSnapshot | undefined): CollectCtx<GitHeadSnapshot | undefined> => ({
|
|
65
|
+
...snapshotCtx(cwd),
|
|
66
|
+
branch: [],
|
|
67
|
+
branchOffset: undefined,
|
|
68
|
+
snapshot,
|
|
69
|
+
skill: "commit",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run the full outcome (collector → parser) end-to-end, returning the
|
|
74
|
+
* commit data the parser produced. Mirrors what `produceAndValidateOutput`
|
|
75
|
+
* does in the runner.
|
|
76
|
+
*/
|
|
77
|
+
const runOutcome = async (cwd: string, snapshot: GitHeadSnapshot | undefined) => {
|
|
78
|
+
const ctx = collectCtx(cwd, snapshot);
|
|
79
|
+
const collected = await gitCommitOutcome.collector.collect(ctx);
|
|
80
|
+
if (collected.kind === "fatal") return collected;
|
|
81
|
+
const parseCtx: ParseCtx<GitHeadSnapshot | undefined> = { ...ctx, artifacts: collected.artifacts };
|
|
82
|
+
return gitCommitOutcome.parser!.parse(parseCtx);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe.runIf(hasGit)("gitHeadSnapshot", () => {
|
|
86
|
+
let tmpDir: string;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
tmpDir = mkdtempSync(join(tmpdir(), "rpiv-git-snap-"));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns the current HEAD SHA in a real repo", async () => {
|
|
97
|
+
initRepo(tmpDir);
|
|
98
|
+
const snap = await gitHeadSnapshot(snapshotCtx(tmpDir));
|
|
99
|
+
expect(snap?.baselineSha).toMatch(/^[0-9a-f]{40}$/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns undefined when cwd is not a git repo (no throw)", async () => {
|
|
103
|
+
const snap = await gitHeadSnapshot(snapshotCtx(tmpDir));
|
|
104
|
+
expect(snap).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns undefined when cwd does not exist (no throw)", async () => {
|
|
108
|
+
const snap = await gitHeadSnapshot(snapshotCtx(join(tmpDir, "does-not-exist")));
|
|
109
|
+
expect(snap).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe.runIf(hasGit)("gitCommitOutcome end-to-end", () => {
|
|
114
|
+
let tmpDir: string;
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
tmpDir = mkdtempSync(join(tmpdir(), "rpiv-git-ext-"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("emits a real commit payload when HEAD moved between snapshot and collect", async () => {
|
|
125
|
+
initRepo(tmpDir);
|
|
126
|
+
const snap = await gitHeadSnapshot(snapshotCtx(tmpDir));
|
|
127
|
+
expect(snap?.baselineSha).toMatch(/^[0-9a-f]{40}$/);
|
|
128
|
+
|
|
129
|
+
writeFileSync(join(tmpDir, "a.txt"), "hello\n");
|
|
130
|
+
execSync("git add a.txt", { cwd: tmpDir });
|
|
131
|
+
execSync('git commit -q -m "add a"', { cwd: tmpDir });
|
|
132
|
+
|
|
133
|
+
const result = await runOutcome(tmpDir, snap);
|
|
134
|
+
expect(result.kind).toBe("ok");
|
|
135
|
+
if (result.kind !== "ok") return;
|
|
136
|
+
expect(result.payload.kind).toBe("git-commit");
|
|
137
|
+
const data = result.payload.data;
|
|
138
|
+
expect(data.sha).toMatch(/^[0-9a-f]{40}$/);
|
|
139
|
+
expect(data.prevSha).toBe(snap?.baselineSha);
|
|
140
|
+
expect(data.subject).toBe("add a");
|
|
141
|
+
expect(data.filesChanged).toBe(1);
|
|
142
|
+
expect(data.noOp).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("emits noOp payload when HEAD did not move", async () => {
|
|
146
|
+
initRepo(tmpDir);
|
|
147
|
+
const snap = await gitHeadSnapshot(snapshotCtx(tmpDir));
|
|
148
|
+
const result = await runOutcome(tmpDir, snap);
|
|
149
|
+
expect(result.kind).toBe("ok");
|
|
150
|
+
if (result.kind !== "ok") return;
|
|
151
|
+
expect(result.payload.data.noOp).toBe(true);
|
|
152
|
+
expect(result.payload.data.prevSha).toBe(snap?.baselineSha);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("emits noOp payload when snapshot is undefined (snapshot failure upstream)", async () => {
|
|
156
|
+
const result = await runOutcome(tmpDir, undefined);
|
|
157
|
+
expect(result.kind).toBe("ok");
|
|
158
|
+
if (result.kind !== "ok") return;
|
|
159
|
+
expect(result.payload.data.noOp).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("emits noOp payload when cwd is not a git repo (collectCommitData returns null)", async () => {
|
|
163
|
+
// Synthesize a snapshot with a fake baseline; collect runs in a non-repo cwd.
|
|
164
|
+
const result = await runOutcome(tmpDir, { baselineSha: "deadbeef" });
|
|
165
|
+
expect(result.kind).toBe("ok");
|
|
166
|
+
if (result.kind !== "ok") return;
|
|
167
|
+
expect(result.payload.data.noOp).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("gitCommitCollector always emits one artifact (the commit handle)", () => {
|
|
172
|
+
let tmpDir: string;
|
|
173
|
+
|
|
174
|
+
beforeEach(() => {
|
|
175
|
+
tmpDir = mkdtempSync(join(tmpdir(), "rpiv-git-res-"));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(() => {
|
|
179
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("emits role:'commit' opaque handle with the post-stage SHA (or empty when unavailable)", async () => {
|
|
183
|
+
const collected = await gitCommitCollector.collect(collectCtx(tmpDir, { baselineSha: "abc" }));
|
|
184
|
+
expect(collected.kind).toBe("ok");
|
|
185
|
+
if (collected.kind !== "ok") return;
|
|
186
|
+
expect(collected.artifacts).toHaveLength(1);
|
|
187
|
+
expect(collected.artifacts[0]?.role).toBe("commit");
|
|
188
|
+
expect(collected.artifacts[0]?.handle.kind).toBe("opaque");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Suppress unused-import lint when this file runs without git on PATH.
|
|
193
|
+
void existsSync;
|
|
194
|
+
void gitCommitParser;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git commit outcome + pre-stage git HEAD snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Split into a collector (detects the commit and emits an `opaque(sha)`
|
|
5
|
+
* handle) and a parser (parses commit metadata into `GitCommitData`).
|
|
6
|
+
* `gitCommitOutcome` is the wired-up pair authors plug into a stage;
|
|
7
|
+
* `gitCommitCollector` and `gitCommitParser` are individually exposed
|
|
8
|
+
* so authors can compose them with other parsers / collectors.
|
|
9
|
+
*
|
|
10
|
+
* Shells out asynchronously via `execFile` so a slow `git` invocation
|
|
11
|
+
* (network-backed working tree, hung FS, large `--shortstat`) can't
|
|
12
|
+
* pin the event loop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { type Artifact, opaque } from "../handle.js";
|
|
18
|
+
import type {
|
|
19
|
+
ArtifactCollector,
|
|
20
|
+
ArtifactParser,
|
|
21
|
+
CollectCtx,
|
|
22
|
+
OutputSpec,
|
|
23
|
+
ParseCtx,
|
|
24
|
+
SnapshotCtx,
|
|
25
|
+
} from "../output-spec.js";
|
|
26
|
+
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Output data shape produced by `gitCommitParser` — co-located with
|
|
31
|
+
* the outcome that emits it. The `GitCommitOutput` alias in
|
|
32
|
+
* `output.ts` re-imports this type so downstream stages can narrow on
|
|
33
|
+
* `output.kind === "git-commit"` without reaching into per-outcome
|
|
34
|
+
* paths.
|
|
35
|
+
*/
|
|
36
|
+
export interface GitCommitData {
|
|
37
|
+
sha: string;
|
|
38
|
+
prevSha: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
filesChanged: number;
|
|
41
|
+
noOp?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Snapshot captured before the stage runs. */
|
|
45
|
+
export interface GitHeadSnapshot {
|
|
46
|
+
baselineSha: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Per git command. 5 s is generous for `rev-parse` / `log -1` / `diff --shortstat` on local repos. */
|
|
50
|
+
const GIT_EXEC_TIMEOUT_MS = 5_000;
|
|
51
|
+
|
|
52
|
+
/** Run a git command from `cwd`, returning trimmed stdout. */
|
|
53
|
+
async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
54
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
55
|
+
cwd,
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
timeout: GIT_EXEC_TIMEOUT_MS,
|
|
58
|
+
});
|
|
59
|
+
return stdout.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pre-stage snapshot: capture the current HEAD SHA via async `execFile`.
|
|
64
|
+
*
|
|
65
|
+
* Async — keeps the event loop responsive even if `git` is slow (network
|
|
66
|
+
* FS, hung mount, contended index). Fail-soft: returns undefined on any
|
|
67
|
+
* failure (not a git repo, git missing, non-zero exit, timeout).
|
|
68
|
+
* `gitCommitCollector` handles `undefined` snapshot gracefully by
|
|
69
|
+
* emitting an artifact carrying a `noOp: true` payload.
|
|
70
|
+
*/
|
|
71
|
+
export async function gitHeadSnapshot(ctx: SnapshotCtx): Promise<GitHeadSnapshot | undefined> {
|
|
72
|
+
try {
|
|
73
|
+
const sha = await git(ctx.cwd, "rev-parse", "HEAD");
|
|
74
|
+
return sha ? { baselineSha: sha } : undefined;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Collector — detect "did HEAD move during this stage?"
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Collector always emits exactly one artifact — even on no-op (HEAD
|
|
86
|
+
* unchanged) or git-unavailable, with the handle's `id` carrying the
|
|
87
|
+
* post-stage SHA (or empty string). The parser interprets the
|
|
88
|
+
* `noOp` flag from the artifact's `meta`. This shape keeps the
|
|
89
|
+
* collector's contract uniform (always one fact) while letting the
|
|
90
|
+
* parser produce the rich `GitCommitData` downstream consumers expect.
|
|
91
|
+
*/
|
|
92
|
+
export const gitCommitCollector: ArtifactCollector<GitHeadSnapshot | undefined> = {
|
|
93
|
+
snapshot: gitHeadSnapshot,
|
|
94
|
+
async collect(ctx: CollectCtx<GitHeadSnapshot | undefined>) {
|
|
95
|
+
const baselineSha = ctx.snapshot?.baselineSha ?? "";
|
|
96
|
+
const headSha = await safeHead(ctx.cwd);
|
|
97
|
+
const artifact: Artifact = {
|
|
98
|
+
handle: opaque(headSha || baselineSha),
|
|
99
|
+
role: "commit",
|
|
100
|
+
meta: { baselineSha, headSha, baselineMissing: !ctx.snapshot },
|
|
101
|
+
};
|
|
102
|
+
return { kind: "ok", artifacts: [artifact] };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
async function safeHead(cwd: string): Promise<string> {
|
|
107
|
+
try {
|
|
108
|
+
return await git(cwd, "rev-parse", "HEAD");
|
|
109
|
+
} catch {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Parser — turn the collected commit handle into typed GitCommitData
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export const gitCommitParser: ArtifactParser<GitHeadSnapshot | undefined, "git-commit", GitCommitData> = {
|
|
119
|
+
async parse(ctx: ParseCtx<GitHeadSnapshot | undefined>) {
|
|
120
|
+
const artifact = ctx.artifacts[0];
|
|
121
|
+
if (!artifact) {
|
|
122
|
+
// Defensive — gitCommitCollector always emits one, but a composed
|
|
123
|
+
// collector may not. Treat as no-op against the baseline.
|
|
124
|
+
return { kind: "ok", payload: { kind: "git-commit", data: noOpData(ctx.snapshot?.baselineSha ?? "") } };
|
|
125
|
+
}
|
|
126
|
+
const meta = artifact.meta as { baselineSha: string; headSha: string; baselineMissing: boolean } | undefined;
|
|
127
|
+
const baselineSha = meta?.baselineSha ?? "";
|
|
128
|
+
const headSha = meta?.headSha ?? "";
|
|
129
|
+
|
|
130
|
+
if (meta?.baselineMissing) {
|
|
131
|
+
return { kind: "ok", payload: { kind: "git-commit", data: noOpData("") } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const data = (await collectCommitData(ctx.cwd, baselineSha, headSha)) ?? noOpData(baselineSha);
|
|
135
|
+
return { kind: "ok", payload: { kind: "git-commit", data } };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// OutputSpec — the wired-up pair
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Git commit outcome — composes `gitCommitCollector` (which carries the
|
|
145
|
+
* `gitHeadSnapshot` snapshot internally) with `gitCommitParser`.
|
|
146
|
+
*
|
|
147
|
+
* Concrete generics: snapshot is `GitHeadSnapshot | undefined`
|
|
148
|
+
* (undefined when not in a git repo), output kind is `"git-commit"`,
|
|
149
|
+
* data is `GitCommitData`.
|
|
150
|
+
*/
|
|
151
|
+
export const gitCommitOutcome: OutputSpec<GitHeadSnapshot | undefined, "git-commit", GitCommitData> = {
|
|
152
|
+
collector: gitCommitCollector,
|
|
153
|
+
parser: gitCommitParser,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Commit-data collection
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build `GitCommitData` given pre/post SHAs already gathered by the
|
|
162
|
+
* collector. Returns `null` if any follow-up git call throws — caller
|
|
163
|
+
* substitutes a baseline-aware no-op payload so the workflow keeps
|
|
164
|
+
* moving.
|
|
165
|
+
*/
|
|
166
|
+
async function collectCommitData(cwd: string, baselineSha: string, headSha: string): Promise<GitCommitData | null> {
|
|
167
|
+
try {
|
|
168
|
+
if (!headSha || headSha === baselineSha) return noOpData(baselineSha, headSha);
|
|
169
|
+
const [subject, filesChanged] = await Promise.all([
|
|
170
|
+
git(cwd, "log", "-1", "--format=%s", headSha),
|
|
171
|
+
countFilesChanged(cwd, baselineSha, headSha),
|
|
172
|
+
]);
|
|
173
|
+
return { sha: headSha, prevSha: baselineSha, subject, filesChanged };
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Parse `git diff --shortstat` output for the "N files changed" count. */
|
|
180
|
+
async function countFilesChanged(cwd: string, baselineSha: string, headSha: string): Promise<number> {
|
|
181
|
+
const diffStat = await git(cwd, "diff", "--shortstat", baselineSha, headSha);
|
|
182
|
+
const match = diffStat.match(/^(\d+) files? changed/);
|
|
183
|
+
return match ? parseInt(match[1]!, 10) : 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const noOpData = (prevSha: string, sha = ""): GitCommitData => ({
|
|
187
|
+
sha,
|
|
188
|
+
prevSha,
|
|
189
|
+
subject: "",
|
|
190
|
+
filesChanged: 0,
|
|
191
|
+
noOp: true,
|
|
192
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel re-exports for the bundled outcomes + their primitive parts.
|
|
3
|
+
*
|
|
4
|
+
* `artifactMdOutcome` is deliberately NOT bundled here — the
|
|
5
|
+
* `.rpiv/artifacts/<bucket>/<file>.md` layout is an rpiv-pi convention,
|
|
6
|
+
* not a framework truth. rpiv-pi ships its own `rpivArtifactMdOutcome`
|
|
7
|
+
* (and `rpivArtifactCollector` / `rpivBucketCollector` helpers) built on
|
|
8
|
+
* top of the framework primitives re-exported from `./collectors` and
|
|
9
|
+
* `./parsers`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export * from "./collectors/index.js";
|
|
13
|
+
export {
|
|
14
|
+
type GitCommitData,
|
|
15
|
+
type GitHeadSnapshot,
|
|
16
|
+
gitCommitCollector,
|
|
17
|
+
gitCommitOutcome,
|
|
18
|
+
gitCommitParser,
|
|
19
|
+
gitHeadSnapshot,
|
|
20
|
+
} from "./git-commit.js";
|
|
21
|
+
export * from "./parsers/index.js";
|
|
22
|
+
export { noopCollector, sideEffectOutcome } from "./side-effect.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled parsers — host-agnostic primitives that turn collected
|
|
3
|
+
* artifacts into the typed `output.data` channel downstream stages
|
|
4
|
+
* see. Re-exported through `outcomes/index.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Format-specific parsers (`frontmatterParser` for markdown-with-YAML)
|
|
7
|
+
* live in the convention layer that owns them — rpiv-pi ships its
|
|
8
|
+
* own. The framework ships only universal interpretations.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { jsonBodyParser } from "./json-body.js";
|