@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,217 @@
1
+ // The append-only event log of one task: `events.jsonl`, one JSON event per
2
+ // line, plus `snapshot.json`, a rebuildable cache of the reduced state
3
+ // (design §4.1).
4
+ //
5
+ // - A writer holds the task lock, appends one complete line with O_APPEND in
6
+ // a single write, and fsyncs. A crash can therefore only leave a torn last
7
+ // line (no trailing newline); readers ignore it and the next locked append
8
+ // truncates it away first. Any other unreadable line is real damage.
9
+ // - Readers are lenient (D17): unknown event keys and unknown event types are
10
+ // ignored, unless the event says `critical: true`, in which case a reader
11
+ // that does not know the type fails closed with an upgrade message.
12
+ // - The snapshot names the event it was taken after (seq, id, byte range). A
13
+ // snapshot that does not match the log is ignored and the log is replayed.
14
+ import { randomUUID } from "node:crypto";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ export const EVENT_VERSION = 1;
19
+
20
+ export type EventActor = { host: string; session: string | null; agentId: string | null };
21
+
22
+ export type StoreEvent = {
23
+ v: number;
24
+ seq: number;
25
+ at: string;
26
+ id: string;
27
+ task: string;
28
+ actor: EventActor | null;
29
+ type: string;
30
+ data: Record<string, unknown>;
31
+ critical?: boolean;
32
+ };
33
+
34
+ export type LogRead = {
35
+ events: StoreEvent[];
36
+ /** Byte offset just past the last complete event line. */
37
+ end: number;
38
+ /** Byte offset where the last complete event line starts. */
39
+ lastStart: number;
40
+ /** File size; larger than `end` when a torn line trails the log. */
41
+ size: number;
42
+ };
43
+
44
+ export class LogDamage extends Error {
45
+ constructor(
46
+ message: string,
47
+ readonly upgrade: boolean = false,
48
+ ) {
49
+ super(message);
50
+ }
51
+ }
52
+
53
+ const isObject = (value: unknown): value is Record<string, unknown> =>
54
+ typeof value === "object" && value !== null && !Array.isArray(value);
55
+
56
+ const parseEvent = (line: string, file: string, offset: number): StoreEvent => {
57
+ let value: unknown;
58
+ try {
59
+ value = JSON.parse(line);
60
+ } catch {
61
+ throw new LogDamage(`${file}: unreadable event at byte ${offset}`);
62
+ }
63
+ if (!isObject(value) || typeof value.v !== "number")
64
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
65
+ if (value.v > EVENT_VERSION)
66
+ throw new LogDamage(
67
+ `${file}: event format v${value.v} was written by a newer Workit; upgrade Workit to read this task`,
68
+ true,
69
+ );
70
+ if (
71
+ typeof value.seq !== "number" ||
72
+ !Number.isInteger(value.seq) ||
73
+ typeof value.type !== "string" ||
74
+ typeof value.id !== "string" ||
75
+ typeof value.task !== "string" ||
76
+ !isObject(value.data)
77
+ )
78
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
79
+ return value as StoreEvent;
80
+ };
81
+
82
+ /**
83
+ * Read the events of `file` starting at byte `from` (a line start). A torn
84
+ * trailing line is excluded; `expectSeq` is the seq the first event must have.
85
+ * Returns null when the file does not exist.
86
+ */
87
+ export function readLog(file: string, from = 0, expectSeq: number | null = null): LogRead | null {
88
+ let fd: number;
89
+ try {
90
+ fd = fs.openSync(file, "r");
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
93
+ throw error;
94
+ }
95
+ let bytes: Buffer;
96
+ let size: number;
97
+ try {
98
+ size = fs.fstatSync(fd).size;
99
+ if (from > size) throw new LogDamage(`${file}: shorter than its snapshot`);
100
+ bytes = Buffer.alloc(size - from);
101
+ let read = 0;
102
+ while (read < bytes.length) {
103
+ const got = fs.readSync(fd, bytes, read, bytes.length - read, from + read);
104
+ if (got === 0) break;
105
+ read += got;
106
+ }
107
+ bytes = bytes.subarray(0, read);
108
+ size = from + read;
109
+ } finally {
110
+ fs.closeSync(fd);
111
+ }
112
+ const events: StoreEvent[] = [];
113
+ let start = 0;
114
+ let lastStart = from;
115
+ let seq = expectSeq;
116
+ for (;;) {
117
+ const newline = bytes.indexOf(0x0a, start);
118
+ if (newline < 0) break;
119
+ const line = bytes.subarray(start, newline).toString("utf8");
120
+ if (line.trim() !== "") {
121
+ const event = parseEvent(line, file, from + start);
122
+ if (seq !== null && event.seq !== seq)
123
+ throw new LogDamage(`${file}: event seq ${event.seq} where ${seq} was expected`);
124
+ seq = event.seq + 1;
125
+ events.push(event);
126
+ lastStart = from + start;
127
+ }
128
+ start = newline + 1;
129
+ }
130
+ return { events, end: from + start, lastStart, size };
131
+ }
132
+
133
+ /** Durably write `dir`'s entries (no-op where directories cannot be fsynced). */
134
+ export const fsyncDirectory = (dir: string): void => {
135
+ try {
136
+ const fd = fs.openSync(dir, "r");
137
+ try {
138
+ fs.fsyncSync(fd);
139
+ } finally {
140
+ fs.closeSync(fd);
141
+ }
142
+ } catch (error) {
143
+ if (process.platform !== "win32") throw error;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Append one event line. `validEnd` is where the last complete line ends: a
149
+ * torn tail beyond it is truncated first. Returns the byte offset the line
150
+ * starts at.
151
+ */
152
+ export function appendEvent(file: string, event: StoreEvent, validEnd: number): number {
153
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
154
+ const created = !fs.existsSync(file);
155
+ // Cut a torn tail first, through a writable handle: an append-only handle
156
+ // cannot truncate on Windows.
157
+ if (!created && fs.statSync(file).size !== validEnd) {
158
+ const cut = fs.openSync(file, "r+");
159
+ try {
160
+ fs.ftruncateSync(cut, validEnd);
161
+ fs.fsyncSync(cut);
162
+ } finally {
163
+ fs.closeSync(cut);
164
+ }
165
+ }
166
+ const fd = fs.openSync(file, "a", 0o600);
167
+ try {
168
+ let written = 0;
169
+ while (written < line.length) written += fs.writeSync(fd, line, written, line.length - written);
170
+ fs.fsyncSync(fd);
171
+ } finally {
172
+ fs.closeSync(fd);
173
+ }
174
+ if (created) fsyncDirectory(path.dirname(file));
175
+ return validEnd;
176
+ }
177
+
178
+ /** Replace `file` with `bytes` atomically (temp file + rename). */
179
+ export function replaceFile(file: string, bytes: string, durable: boolean): void {
180
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
181
+ try {
182
+ const fd = fs.openSync(temporary, "wx", 0o600);
183
+ try {
184
+ fs.writeSync(fd, bytes);
185
+ if (durable) fs.fsyncSync(fd);
186
+ } finally {
187
+ fs.closeSync(fd);
188
+ }
189
+ renameWithRetry(temporary, file);
190
+ if (durable) fsyncDirectory(path.dirname(file));
191
+ } catch (error) {
192
+ try {
193
+ fs.unlinkSync(temporary);
194
+ } catch {}
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
200
+ /** Windows briefly refuses to replace a file another process is reading. */
201
+ export const isTransientWindowsError = (error: unknown): boolean => {
202
+ if (process.platform !== "win32") return false;
203
+ const code = (error as { code?: unknown } | null)?.code;
204
+ return typeof code === "string" && TRANSIENT_WINDOWS_CODES.has(code);
205
+ };
206
+ export const retryTransient = <T>(run: () => T): T => {
207
+ if (process.platform !== "win32") return run();
208
+ for (let attempt = 0; ; attempt += 1) {
209
+ try {
210
+ return run();
211
+ } catch (error) {
212
+ if (attempt >= 20 || !isTransientWindowsError(error)) throw error;
213
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 * (attempt + 1));
214
+ }
215
+ }
216
+ };
217
+ const renameWithRetry = (from: string, to: string) => retryTransient(() => fs.renameSync(from, to));
@@ -0,0 +1,29 @@
1
+ // One-time notes from the store to the user. A host installs a reporter on
2
+ // the global `Symbol.for("workit.migrationReporter")` slot (the CLI router
3
+ // does; it must not import workit-core on its cold path). Without one, the
4
+ // note is silent: hooks and in-process hosts own their output channels.
5
+
6
+ export type MigrationReport = {
7
+ /** The 2.x store that was migrated. */
8
+ from: string;
9
+ /** The store it now lives in. */
10
+ to: string;
11
+ /** Where the 2.x files were backed up. */
12
+ backup: string;
13
+ tasks: number;
14
+ skipped: number;
15
+ workspace: boolean;
16
+ };
17
+
18
+ const SLOT = Symbol.for("workit.migrationReporter");
19
+
20
+ export const reportMigration = (report: MigrationReport): void => {
21
+ try {
22
+ const reporter = (globalThis as Record<symbol, unknown>)[SLOT];
23
+ if (typeof reporter === "function") reporter(report);
24
+ } catch {}
25
+ };
26
+
27
+ /** Whether a host installed a reporter (it can tell the user about a migration). */
28
+ export const migrationReporterInstalled = (): boolean =>
29
+ typeof (globalThis as Record<symbol, unknown>)[SLOT] === "function";
@@ -0,0 +1,135 @@
1
+ // Structural JSON diff for the task event log (design §4.1). One mutation
2
+ // becomes the smallest set of operations that turns the previous record into
3
+ // the next one, so the log grows with the change, never with the record:
4
+ //
5
+ // set replace the value at `path`
6
+ // del remove the key at `path`
7
+ // push append `values` to the array at `path`
8
+ // cut truncate the array at `path` to `length`
9
+ //
10
+ // Arrays are compared element by element, so appending an entry (evidence,
11
+ // findings, decisions) is one `push`, and editing one entry in place is a
12
+ // diff inside that entry. Paths are arrays of keys and indices.
13
+
14
+ export type PatchPath = (string | number)[];
15
+ export type PatchOp =
16
+ | { op: "set"; path: PatchPath; value: unknown }
17
+ | { op: "del"; path: PatchPath }
18
+ | { op: "push"; path: PatchPath; values: unknown[] }
19
+ | { op: "cut"; path: PatchPath; length: number };
20
+
21
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
22
+ typeof value === "object" && value !== null && !Array.isArray(value);
23
+
24
+ const equal = (left: unknown, right: unknown): boolean => {
25
+ if (left === right) return true;
26
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
27
+ return false;
28
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
29
+ if (Array.isArray(left)) {
30
+ const other = right as unknown[];
31
+ return left.length === other.length && left.every((item, index) => equal(item, other[index]));
32
+ }
33
+ const leftKeys = Object.keys(left).filter(
34
+ (key) => (left as Record<string, unknown>)[key] !== undefined,
35
+ );
36
+ const rightKeys = Object.keys(right).filter(
37
+ (key) => (right as Record<string, unknown>)[key] !== undefined,
38
+ );
39
+ return (
40
+ leftKeys.length === rightKeys.length &&
41
+ leftKeys.every((key) =>
42
+ equal((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]),
43
+ )
44
+ );
45
+ };
46
+
47
+ /** Operations that turn `before` into `after` (JSON values only). */
48
+ export function diff(before: unknown, after: unknown, path: PatchPath = []): PatchOp[] {
49
+ if (equal(before, after)) return [];
50
+ if (isPlainObject(before) && isPlainObject(after)) {
51
+ const ops: PatchOp[] = [];
52
+ for (const key of Object.keys(before))
53
+ if (before[key] !== undefined && after[key] === undefined)
54
+ ops.push({ op: "del", path: [...path, key] });
55
+ for (const key of Object.keys(after)) {
56
+ if (after[key] === undefined) continue;
57
+ if (before[key] === undefined)
58
+ ops.push({ op: "set", path: [...path, key], value: after[key] });
59
+ else ops.push(...diff(before[key], after[key], [...path, key]));
60
+ }
61
+ return ops;
62
+ }
63
+ if (Array.isArray(before) && Array.isArray(after)) {
64
+ const ops: PatchOp[] = [];
65
+ if (after.length < before.length) ops.push({ op: "cut", path, length: after.length });
66
+ const shared = Math.min(before.length, after.length);
67
+ // A rewrite of most of the array is cheaper as one value.
68
+ let changed = 0;
69
+ for (let index = 0; index < shared; index += 1)
70
+ if (!equal(before[index], after[index])) changed += 1;
71
+ if (shared > 0 && changed > shared / 2 && changed > 4)
72
+ return [{ op: "set", path, value: after }];
73
+ for (let index = 0; index < shared; index += 1)
74
+ ops.push(...diff(before[index], after[index], [...path, index]));
75
+ if (after.length > before.length)
76
+ ops.push({ op: "push", path, values: after.slice(before.length) });
77
+ return ops;
78
+ }
79
+ return [{ op: "set", path, value: after }];
80
+ }
81
+
82
+ const clone = <T>(value: T): T => structuredClone(value);
83
+
84
+ /** Apply `ops` to `target` in place; throws on a path that does not exist. */
85
+ export function apply(target: unknown, ops: readonly PatchOp[]): unknown {
86
+ let root = target;
87
+ for (const op of ops) {
88
+ if (op.path.length === 0) {
89
+ if (op.op === "set") root = clone(op.value);
90
+ else if (op.op === "push" && Array.isArray(root)) root.push(...clone(op.values));
91
+ else if (op.op === "cut" && Array.isArray(root)) root.length = op.length;
92
+ else throw new Error(`patch: ${op.op} at the root is invalid`);
93
+ continue;
94
+ }
95
+ let parent: unknown = root;
96
+ for (const key of op.path.slice(0, -1)) {
97
+ if (typeof parent !== "object" || parent === null)
98
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
99
+ parent = (parent as Record<string | number, unknown>)[key];
100
+ }
101
+ if (typeof parent !== "object" || parent === null)
102
+ throw new Error(`patch: missing path ${op.path.join(".")}`);
103
+ const key = op.path.at(-1)!;
104
+ const container = parent as Record<string | number, unknown>;
105
+ if (op.op === "set") container[key] = clone(op.value);
106
+ else if (op.op === "del") delete container[key];
107
+ else {
108
+ const array = container[key];
109
+ if (!Array.isArray(array)) throw new Error(`patch: ${op.path.join(".")} is not an array`);
110
+ if (op.op === "push") array.push(...clone(op.values));
111
+ else array.length = op.length;
112
+ }
113
+ }
114
+ return root;
115
+ }
116
+
117
+ const isPath = (value: unknown): value is PatchPath =>
118
+ Array.isArray(value) &&
119
+ value.every(
120
+ (key) =>
121
+ typeof key === "string" || (typeof key === "number" && Number.isInteger(key) && key >= 0),
122
+ );
123
+
124
+ /** Structural check of untrusted ops (from disk). */
125
+ export const isPatch = (value: unknown): value is PatchOp[] =>
126
+ Array.isArray(value) &&
127
+ value.every((op) => {
128
+ if (!isPlainObject(op) || !isPath(op.path)) return false;
129
+ if (op.op === "set") return "value" in op;
130
+ if (op.op === "del") return op.path.length > 0;
131
+ if (op.op === "push") return Array.isArray(op.values);
132
+ if (op.op === "cut")
133
+ return typeof op.length === "number" && Number.isInteger(op.length) && op.length >= 0;
134
+ return false;
135
+ });