@markjaquith/agency 2.29.0 → 2.30.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.
Files changed (41) hide show
  1. package/README.md +60 -10
  2. package/cli.ts +3 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +25 -15
  6. package/skills/agency/references/commands.md +32 -16
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +50 -12
  9. package/src/cli-parser.test.ts +7 -0
  10. package/src/cli-parser.ts +13 -2
  11. package/src/cli.test.ts +205 -3
  12. package/src/commands/pr.test.ts +20 -1
  13. package/src/commands/repo.test.ts +66 -1
  14. package/src/commands/repo.ts +35 -8
  15. package/src/commands/status.test.ts +22 -0
  16. package/src/commands/status.ts +1 -0
  17. package/src/commands/sync.ts +5 -3
  18. package/src/graph-schema.test.ts +52 -4
  19. package/src/protocol.test.ts +41 -8
  20. package/src/readiness.test.ts +75 -17
  21. package/src/services/DoctorService.ts +22 -13
  22. package/src/services/EpicService.ts +1 -1
  23. package/src/services/GraphMutationService.ts +3 -3
  24. package/src/services/GraphService.ts +13 -4
  25. package/src/services/PhaseService.ts +1 -1
  26. package/src/services/ReadinessService.test.ts +47 -0
  27. package/src/services/RepositoryService.test.ts +299 -5
  28. package/src/services/RepositoryService.ts +725 -98
  29. package/src/services/SyncService.test.ts +36 -0
  30. package/src/services/SyncService.ts +20 -1
  31. package/src/services/TaskService.ts +1 -1
  32. package/src/services/WorkbaseService.test.ts +32 -0
  33. package/src/services/WorkbaseService.ts +22 -14
  34. package/src/services/WorktreeLock.test.ts +122 -0
  35. package/src/services/WorktreeService.test.ts +17 -2
  36. package/src/services/WorktreeService.ts +3 -3
  37. package/src/utils/process.test.ts +4 -3
  38. package/src/workbase/AGENTS.md +5 -0
  39. package/src/workbase/dependency-graph.test.ts +50 -0
  40. package/src/workbase/schemas.test.ts +52 -0
  41. package/src/workbase/schemas.ts +19 -0
package/src/cli.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from "bun:test"
1
+ import { afterAll, afterEach, describe, expect, test } from "bun:test"
2
2
  import { access, mkdir, realpath } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
4
  import errorFixture from "../fixtures/protocol/error.json"
@@ -7,6 +7,9 @@ import { cleanupTempDir, createTempDir } from "./test-utils"
7
7
 
8
8
  const projectRoot = join(import.meta.dir, "..")
9
9
  const cliPath = join(projectRoot, "cli.ts")
10
+ const isolatedConfigHome = await createTempDir()
11
+
12
+ afterAll(() => cleanupTempDir(isolatedConfigHome))
10
13
 
11
14
  interface CliResult {
12
15
  exitCode: number
@@ -21,7 +24,11 @@ async function runCli(
21
24
  ): Promise<CliResult> {
22
25
  const subprocess = Bun.spawn([process.execPath, cliPath, ...args], {
23
26
  cwd,
24
- env: env ? { ...process.env, ...env } : undefined,
27
+ env: {
28
+ ...process.env,
29
+ XDG_CONFIG_HOME: isolatedConfigHome,
30
+ ...env,
31
+ },
25
32
  stdout: "pipe",
26
33
  stderr: "pipe",
27
34
  })
@@ -34,13 +41,55 @@ async function runCli(
34
41
  }
35
42
 
36
43
  function parseJson(result: CliResult) {
37
- expect(result.exitCode).toBe(0)
44
+ expect(result.exitCode, JSON.stringify(result)).toBe(0)
38
45
  expect(result.stderr).toBe("")
39
46
  const envelope = JSON.parse(result.stdout)
40
47
  expect(envelope).toMatchObject({ version: 1, ok: true })
41
48
  return envelope.result
42
49
  }
43
50
 
51
+ async function runGit(args: string[]) {
52
+ const subprocess = Bun.spawn(["git", ...args], {
53
+ stdout: "pipe",
54
+ stderr: "pipe",
55
+ })
56
+ const [exitCode, stdout, stderr] = await Promise.all([
57
+ subprocess.exited,
58
+ new Response(subprocess.stdout).text(),
59
+ new Response(subprocess.stderr).text(),
60
+ ])
61
+ if (exitCode !== 0) throw new Error(stderr)
62
+ return stdout
63
+ }
64
+
65
+ async function startGitDaemon(basePath: string) {
66
+ const port = 20000 + Math.floor(Math.random() * 20000)
67
+ const process = Bun.spawn(
68
+ [
69
+ "git",
70
+ "daemon",
71
+ "--reuseaddr",
72
+ "--export-all",
73
+ `--base-path=${basePath}`,
74
+ "--listen=127.0.0.1",
75
+ `--port=${port}`,
76
+ basePath,
77
+ ],
78
+ { stdout: "pipe", stderr: "pipe" },
79
+ )
80
+ const remote = `git://127.0.0.1:${port}/source.git`
81
+ for (let attempt = 0; attempt < 40; attempt++) {
82
+ const probe = Bun.spawn(["git", "ls-remote", remote], {
83
+ stdout: "ignore",
84
+ stderr: "ignore",
85
+ })
86
+ if ((await probe.exited) === 0) return { process, remote }
87
+ await Bun.sleep(25)
88
+ }
89
+ process.kill()
90
+ throw new Error("Git daemon did not start")
91
+ }
92
+
44
93
  describe("CLI", () => {
45
94
  const tempDirs: string[] = []
46
95
 
@@ -550,6 +599,22 @@ describe("CLI", () => {
550
599
  kind: "task",
551
600
  taskId: "explicit",
552
601
  })
602
+ const nextByCwd = parseJson(
603
+ await runCli(
604
+ ["next", "--cwd", root, "--select", "--json"],
605
+ projectRoot,
606
+ env,
607
+ ),
608
+ )
609
+ expect(nextByCwd.selected.key).toBe("task/explicit")
610
+ const nextByRegistration = parseJson(
611
+ await runCli(
612
+ ["next", "--workbase", "primary", "--select", "--json"],
613
+ projectRoot,
614
+ env,
615
+ ),
616
+ )
617
+ expect(nextByRegistration.selected.key).toBe("task/explicit")
553
618
 
554
619
  parseJson(
555
620
  await runCli(["workbase", "default", "primary", "--json"], parent, env),
@@ -662,11 +727,23 @@ status: open
662
727
  ["config", "user.name", "Test"],
663
728
  ["add", "README.md"],
664
729
  ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
730
+ ["remote", "add", "origin", source],
665
731
  ]) {
666
732
  expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
667
733
  }
668
734
 
669
735
  parseJson(await runCli(["init", root, "--json"], parent))
736
+ await Bun.write(
737
+ join(root, "agency.json"),
738
+ JSON.stringify({
739
+ version: 2,
740
+ repositories: {
741
+ agency: {
742
+ remote: "https://example.com/agency-tests/source.git",
743
+ },
744
+ },
745
+ }),
746
+ )
670
747
  parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
671
748
  parseJson(
672
749
  await runCli(
@@ -789,6 +866,14 @@ status: open
789
866
  stderr: "pipe",
790
867
  })
791
868
  expect(await git.exited).toBe(0)
869
+ await runGit([
870
+ "-C",
871
+ source,
872
+ "remote",
873
+ "add",
874
+ "origin",
875
+ "https://example.com/agency-tests/source.git",
876
+ ])
792
877
 
793
878
  expect(parseJson(await runCli(["init", root, "--json"]))).toEqual({
794
879
  root,
@@ -981,4 +1066,121 @@ status: open
981
1066
  valid: true,
982
1067
  })
983
1068
  }, 30_000)
1069
+
1070
+ test("restores portable repositories in a fresh workbase clone", async () => {
1071
+ const parent = await createTempDir()
1072
+ tempDirs.push(parent)
1073
+ const sourceWorktree = join(parent, "source-worktree")
1074
+ const source = join(parent, "source.git")
1075
+ const root = join(parent, "workbase")
1076
+ const restored = join(parent, "restored")
1077
+
1078
+ await runGit(["init", "--initial-branch=main", sourceWorktree])
1079
+ await Bun.write(join(sourceWorktree, "README.md"), "portable\n")
1080
+ await runGit([
1081
+ "-C",
1082
+ sourceWorktree,
1083
+ "config",
1084
+ "user.email",
1085
+ "test@example.com",
1086
+ ])
1087
+ await runGit(["-C", sourceWorktree, "config", "user.name", "Test"])
1088
+ await runGit(["-C", sourceWorktree, "add", "README.md"])
1089
+ await runGit([
1090
+ "-C",
1091
+ sourceWorktree,
1092
+ "-c",
1093
+ "commit.gpgsign=false",
1094
+ "commit",
1095
+ "-m",
1096
+ "initial",
1097
+ ])
1098
+ await runGit(["clone", "--bare", sourceWorktree, source])
1099
+ const daemon = await startGitDaemon(parent)
1100
+
1101
+ try {
1102
+ parseJson(await runCli(["init", root, "--json"], parent))
1103
+ parseJson(
1104
+ await runCli(["repo", "add", "agency", daemon.remote, "--json"], root),
1105
+ )
1106
+ parseJson(
1107
+ await runCli(
1108
+ [
1109
+ "task",
1110
+ "create",
1111
+ "portable",
1112
+ "--repo",
1113
+ "agency",
1114
+ "--branch",
1115
+ "feat/portable",
1116
+ "--base",
1117
+ "main",
1118
+ "--json",
1119
+ ],
1120
+ root,
1121
+ ),
1122
+ )
1123
+
1124
+ await runGit(["init", "--initial-branch=main", root])
1125
+ await runGit(["-C", root, "config", "user.email", "test@example.com"])
1126
+ await runGit(["-C", root, "config", "user.name", "Test"])
1127
+ await runGit(["-C", root, "add", "."])
1128
+ await runGit([
1129
+ "-C",
1130
+ root,
1131
+ "-c",
1132
+ "commit.gpgsign=false",
1133
+ "commit",
1134
+ "-m",
1135
+ "portable workbase",
1136
+ ])
1137
+ const tracked = await runGit(["-C", root, "ls-files"])
1138
+ expect(tracked).toContain("agency.json")
1139
+ expect(tracked).not.toContain("repos/agency")
1140
+
1141
+ await runGit(["clone", root, restored])
1142
+ const planned = parseJson(
1143
+ await runCli(["repo", "setup", "--dry-run", "--json"], restored),
1144
+ )
1145
+ expect(planned.actions).toEqual([
1146
+ expect.objectContaining({
1147
+ alias: "agency",
1148
+ kind: "materialize",
1149
+ status: "planned",
1150
+ }),
1151
+ ])
1152
+ expect(await Bun.file(join(restored, "repos/agency/HEAD")).exists()).toBe(
1153
+ false,
1154
+ )
1155
+
1156
+ const applied = parseJson(
1157
+ await runCli(["repo", "setup", "--apply", "--json"], restored),
1158
+ )
1159
+ expect(applied.actions[0]).toMatchObject({
1160
+ alias: "agency",
1161
+ status: "applied",
1162
+ })
1163
+ expect(await Bun.file(join(restored, "repos/agency/HEAD")).exists()).toBe(
1164
+ true,
1165
+ )
1166
+
1167
+ const prepared = parseJson(
1168
+ await runCli(["work", "prepare", "portable", "--json"], restored),
1169
+ )
1170
+ expect(prepared.checkouts).toEqual([
1171
+ expect.objectContaining({
1172
+ repo: "agency",
1173
+ action: "created",
1174
+ }),
1175
+ ])
1176
+ expect(
1177
+ await Bun.file(
1178
+ join(restored, "tasks/portable/code/agency/README.md"),
1179
+ ).text(),
1180
+ ).toBe("portable\n")
1181
+ } finally {
1182
+ daemon.process.kill()
1183
+ await daemon.process.exited
1184
+ }
1185
+ }, 30_000)
984
1186
  })
@@ -7,20 +7,39 @@ import { pr } from "./pr"
7
7
  describe("pr command", () => {
8
8
  test("outputs the created pull request URL as JSON", async () => {
9
9
  const url = "https://github.com/markjaquith/agency/pull/123"
10
+ let received: unknown[] = []
10
11
  const logs = await captureLogs(() =>
11
12
  Effect.runPromise(
12
13
  pr({
13
14
  subcommand: "create",
14
15
  taskId: "example",
16
+ phaseId: "implementation",
17
+ draft: true,
18
+ force: true,
19
+ cwd: "/workbase",
15
20
  json: true,
16
21
  }).pipe(
17
22
  Effect.provideService(PullRequestService, {
18
- create: () => Effect.succeed(url),
23
+ create: (...args: unknown[]) => {
24
+ received = args
25
+ return Effect.succeed(url)
26
+ },
19
27
  } as never),
20
28
  ) as Effect.Effect<void, unknown, never>,
21
29
  ),
22
30
  )
23
31
 
24
32
  expect(JSON.parse(logs[0]!)).toEqual({ url })
33
+ expect(received).toEqual([
34
+ "example",
35
+ "implementation",
36
+ true,
37
+ "/workbase",
38
+ expect.objectContaining({
39
+ force: true,
40
+ draft: true,
41
+ json: true,
42
+ }),
43
+ ])
25
44
  })
26
45
  })
@@ -23,7 +23,7 @@ describe("repo command", () => {
23
23
  test("requires a subcommand", async () => {
24
24
  await expect(
25
25
  runTestEffect(repo({ args: [], silent: true })),
26
- ).rejects.toThrow("Available subcommands: add, link, list")
26
+ ).rejects.toThrow("Available subcommands: setup, add, link, list")
27
27
  })
28
28
 
29
29
  test("requires add arguments", async () => {
@@ -47,7 +47,9 @@ describe("repo command", () => {
47
47
  path: join(root, "repos/agency"),
48
48
  kind: "repository",
49
49
  remote: null,
50
+ declaredRemote: null,
50
51
  target: null,
52
+ states: ["materialized", "invalid"],
51
53
  },
52
54
  ])
53
55
  })
@@ -75,6 +77,19 @@ describe("repo command", () => {
75
77
  stderr: "ignore",
76
78
  })
77
79
  expect(await git.exited).toBe(0)
80
+ const remote = Bun.spawn(
81
+ [
82
+ "git",
83
+ "-C",
84
+ target,
85
+ "remote",
86
+ "add",
87
+ "origin",
88
+ "https://example.com/linked.git",
89
+ ],
90
+ { stdout: "ignore", stderr: "ignore" },
91
+ )
92
+ expect(await remote.exited).toBe(0)
78
93
 
79
94
  const logs = await captureLogs(() =>
80
95
  runTestEffect(
@@ -101,6 +116,19 @@ describe("repo command", () => {
101
116
  stderr: "ignore",
102
117
  })
103
118
  expect(await git.exited).toBe(0)
119
+ const remote = Bun.spawn(
120
+ [
121
+ "git",
122
+ "-C",
123
+ target,
124
+ "remote",
125
+ "add",
126
+ "origin",
127
+ "https://example.com/unlink.git",
128
+ ],
129
+ { stdout: "ignore", stderr: "ignore" },
130
+ )
131
+ expect(await remote.exited).toBe(0)
104
132
  await runTestEffect(
105
133
  repo({
106
134
  subcommand: "link",
@@ -120,5 +148,42 @@ describe("repo command", () => {
120
148
  )
121
149
 
122
150
  expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
151
+ expect(await Bun.file(join(root, "repos/linked")).exists()).toBe(false)
152
+ const logs = await captureLogs(() =>
153
+ runTestEffect(
154
+ repo({ subcommand: "list", args: [], cwd: root, json: true }),
155
+ ),
156
+ )
157
+ expect(
158
+ JSON.parse(logs[0]!).map(({ alias }: { alias: string }) => alias),
159
+ ).toEqual(["agency", "linked"])
160
+ })
161
+
162
+ test("reports setup plans as JSON without mutating", async () => {
163
+ await Bun.write(
164
+ join(root, "agency.json"),
165
+ JSON.stringify({
166
+ version: 2,
167
+ repositories: {
168
+ missing: { remote: "https://example.com/missing.git" },
169
+ },
170
+ }),
171
+ )
172
+ const logs = await captureLogs(() =>
173
+ runTestEffect(
174
+ repo({ subcommand: "setup", args: [], cwd: root, json: true }),
175
+ ),
176
+ )
177
+ const result = JSON.parse(logs[0]!)
178
+ expect(result.mode).toBe("dry-run")
179
+ expect(result.actions).toEqual([
180
+ {
181
+ kind: "materialize",
182
+ alias: "missing",
183
+ remote: "https://example.com/missing.git",
184
+ status: "planned",
185
+ },
186
+ ])
187
+ expect(await Bun.file(join(root, "repos/missing")).exists()).toBe(false)
123
188
  })
124
189
  })
@@ -7,6 +7,8 @@ interface RepoOptions extends BaseCommandOptions {
7
7
  readonly subcommand?: string
8
8
  readonly args: readonly string[]
9
9
  readonly json?: boolean
10
+ readonly apply?: boolean
11
+ readonly dryRun?: boolean
10
12
  }
11
13
 
12
14
  const requireArg = (args: readonly string[], index: number, usage: string) =>
@@ -19,6 +21,27 @@ export const repo = (options: RepoOptions) =>
19
21
  const cwd = options.cwd ?? process.cwd()
20
22
 
21
23
  switch (options.subcommand) {
24
+ case "setup": {
25
+ const result = yield* repositories.setup({
26
+ cwd,
27
+ apply: options.apply === true,
28
+ })
29
+ if (options.json) {
30
+ log(JSON.stringify(result, null, 2))
31
+ return
32
+ }
33
+ if (result.actions.length === 0) log("Repository setup is current")
34
+ for (const action of result.actions) {
35
+ log(
36
+ `${action.status === "applied" ? "Applied" : "Planned"} ${action.kind} '${action.alias}' from ${action.remote}`,
37
+ )
38
+ }
39
+ for (const issue of result.unresolved) {
40
+ log(`Unresolved '${issue.alias}': ${issue.message}. ${issue.action}`)
41
+ }
42
+ return
43
+ }
44
+
22
45
  case "add": {
23
46
  const [alias, remote] = options.args
24
47
  if (!alias || !remote) {
@@ -58,8 +81,9 @@ export const repo = (options: RepoOptions) =>
58
81
  return
59
82
  }
60
83
  for (const item of items) {
61
- const detail = item.target ?? item.remote ?? item.path
62
- log(`${item.alias}\t${item.kind}\t${detail}`)
84
+ const detail =
85
+ item.target ?? item.remote ?? item.declaredRemote ?? item.path
86
+ log(`${item.alias}\t${item.states.join(",")}\t${detail}`)
63
87
  }
64
88
  return
65
89
  }
@@ -74,7 +98,7 @@ export const repo = (options: RepoOptions) =>
74
98
  log(
75
99
  options.json
76
100
  ? JSON.stringify(item, null, 2)
77
- : `${item.alias}\t${item.kind}\t${item.target ?? item.remote ?? item.path}`,
101
+ : `${item.alias}\t${item.states.join(",")}\t${item.target ?? item.remote ?? item.declaredRemote ?? item.path}`,
78
102
  )
79
103
  return
80
104
  }
@@ -136,7 +160,7 @@ export const repo = (options: RepoOptions) =>
136
160
  log(
137
161
  options.json
138
162
  ? JSON.stringify(item, null, 2)
139
- : (item.remote ?? "No origin remote configured"),
163
+ : (item.declaredRemote ?? "No portable remote declared"),
140
164
  )
141
165
  return
142
166
  }
@@ -163,7 +187,7 @@ export const repo = (options: RepoOptions) =>
163
187
  default:
164
188
  return yield* Effect.fail(
165
189
  new Error(
166
- "Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
190
+ "Subcommand is required. Available subcommands: setup, add, link, list, show, fetch, remove, unlink, rename, remote, verify",
167
191
  ),
168
192
  )
169
193
  }
@@ -173,17 +197,20 @@ export const help = `
173
197
  Usage: agency repo <subcommand>
174
198
 
175
199
  Subcommands:
200
+ setup Plan or apply portable repository setup
176
201
  add <alias> <remote> Create a bare clone
177
202
  link <alias> <path> Link an existing Git repository
178
203
  list List repository aliases
179
204
  show <alias> Show a repository alias
180
205
  fetch <alias> Fetch and prune a repository
181
- remove <alias> Remove an unused repository alias
182
- unlink <alias> Remove an unused linked alias
206
+ remove <alias> Remove a declaration and local materialization
207
+ unlink <alias> Remove only this machine's linked checkout
183
208
  rename <old> <new> Rename an unused repository alias
184
- remote <alias> [url] Show or update the origin remote
209
+ remote <alias> [url] Show or update the portable remote declaration
185
210
  verify <alias> Verify repository operation
186
211
 
187
212
  Options:
213
+ --dry-run Report setup changes without applying them (default)
214
+ --apply Apply safe setup changes
188
215
  --json Output repository aliases as JSON
189
216
  `
@@ -41,7 +41,9 @@ describe("status command", () => {
41
41
  path: join(root, "repos/agency"),
42
42
  kind: "repository",
43
43
  remote: null,
44
+ declaredRemote: null,
44
45
  target: null,
46
+ states: ["materialized", "invalid"],
45
47
  },
46
48
  ])
47
49
  })
@@ -58,6 +60,25 @@ describe("status command", () => {
58
60
  silent: true,
59
61
  }),
60
62
  )
63
+ await runTestEffect(
64
+ task({
65
+ subcommand: "create",
66
+ args: ["finished"],
67
+ repo: "agency",
68
+ branch: "feat/finished",
69
+ base: "main",
70
+ cwd: root,
71
+ silent: true,
72
+ }),
73
+ )
74
+ await runTestEffect(
75
+ task({
76
+ subcommand: "status",
77
+ args: ["finished", "done"],
78
+ cwd: root,
79
+ silent: true,
80
+ }),
81
+ )
61
82
 
62
83
  const logs = await captureLogs(() =>
63
84
  runTestEffect(
@@ -71,6 +92,7 @@ describe("status command", () => {
71
92
  expect(logs.at(-1)).toContain(
72
93
  "task example - open ready agency feat/example absent absent",
73
94
  )
95
+ expect(logs.at(-1)).not.toContain("finished")
74
96
  })
75
97
 
76
98
  test("reports validation issues without requiring a decodable graph", async () => {
@@ -42,6 +42,7 @@ export const status = (options: StatusOptions = {}) =>
42
42
 
43
43
  log(`Workbase: ${report.root}`)
44
44
  log(`Repositories: ${repos.length}`)
45
+ for (const repo of repos) log(` ${repo.alias}: ${repo.states.join(", ")}`)
45
46
  log(`Epics: ${report.epicCount}`)
46
47
  log(`Tasks: ${report.taskCount}`)
47
48
  log(`Phases: ${report.phaseCount}`)
@@ -22,15 +22,17 @@ export const sync = (options: SyncCommandOptions = {}) =>
22
22
  export const help = `
23
23
  Usage: agency sync [--dry-run | --apply] [--json]
24
24
 
25
- Compare declared execution state with Git worktrees, branches, references, claims,
26
- and GitHub pull requests. Dry-run is the default.
25
+ Compare portable repository declarations and execution state with local Git
26
+ repositories, worktrees, branches, references, claims, and pull requests.
27
+ Dry-run is the default.
27
28
 
28
29
  Options:
29
30
  --dry-run Report planned safe transitions without changing state
30
31
  --apply Apply safe reconciliation transitions
31
32
  --json Output one versioned machine result
32
33
 
33
- Apply may materialize unambiguous missing checkouts, release expired claims,
34
+ Apply may materialize declared repositories and unambiguous missing checkouts,
35
+ adopt legacy repositories with portable origins, release expired claims,
34
36
  record a uniquely matched PR, and mark merged work done. Dirty, stale, or
35
37
  conflicting checkouts are always left unresolved.
36
38
  `
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test"
2
+ import { Schema } from "@effect/schema"
2
3
  import jsonSchema from "../schemas/agency-graph-v1.schema.json"
3
- import { graphJsonlRecords, type AgencyGraph } from "./graph-schema"
4
+ import { AgencyGraph, graphJsonlRecords } from "./graph-schema"
4
5
 
5
6
  describe("graph contract", () => {
6
7
  test("publishes the v1 JSON Schema", () => {
@@ -29,6 +30,38 @@ describe("graph contract", () => {
29
30
  })
30
31
 
31
32
  test("streams records that reconstruct graph semantics", () => {
33
+ const nodes = [
34
+ {
35
+ id: "repository:agency",
36
+ key: "agency",
37
+ kind: "repository" as const,
38
+ dependents: ["repository:effect"],
39
+ repositories: ["agency"],
40
+ status: null,
41
+ readiness: null,
42
+ aggregate: null,
43
+ data: { alias: "agency" },
44
+ },
45
+ {
46
+ id: "repository:effect",
47
+ key: "effect",
48
+ kind: "repository" as const,
49
+ dependents: [],
50
+ repositories: ["effect"],
51
+ status: null,
52
+ readiness: null,
53
+ aggregate: null,
54
+ data: { alias: "effect" },
55
+ },
56
+ ]
57
+ const edges = [
58
+ {
59
+ id: "references:repository:agency:repository:effect",
60
+ kind: "references" as const,
61
+ from: "repository:agency",
62
+ to: "repository:effect",
63
+ },
64
+ ]
32
65
  const graph = {
33
66
  version: 1,
34
67
  workbase: { version: 2 },
@@ -40,8 +73,8 @@ describe("graph contract", () => {
40
73
  kinds: [],
41
74
  },
42
75
  includes: [],
43
- nodes: [],
44
- edges: [],
76
+ nodes,
77
+ edges,
45
78
  summary: {
46
79
  status: "open",
47
80
  total: 0,
@@ -54,6 +87,11 @@ describe("graph contract", () => {
54
87
  },
55
88
  validation: { valid: true, issues: [] },
56
89
  } satisfies AgencyGraph
90
+ expect(
91
+ Schema.decodeUnknownSync(AgencyGraph, { onExcessProperty: "error" })(
92
+ graph,
93
+ ),
94
+ ).toEqual(graph)
57
95
  const records = [...graphJsonlRecords(graph)]
58
96
  const { nodes: _nodes, edges: _edges, ...metadata } = graph
59
97
  expect(records).toEqual([
@@ -62,7 +100,17 @@ describe("graph contract", () => {
62
100
  type: "meta",
63
101
  graph: metadata,
64
102
  },
65
- { version: 1, type: "end", nodeCount: 0, edgeCount: 0 },
103
+ ...nodes.map((node) => ({
104
+ version: 1 as const,
105
+ type: "node" as const,
106
+ node,
107
+ })),
108
+ ...edges.map((edge) => ({
109
+ version: 1 as const,
110
+ type: "edge" as const,
111
+ edge,
112
+ })),
113
+ { version: 1, type: "end", nodeCount: 2, edgeCount: 1 },
66
114
  ])
67
115
  })
68
116
  })