@agentsfleet/orly 0.5.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 (112) hide show
  1. package/.githooks/pre-commit +42 -0
  2. package/.githooks/pre-push +67 -0
  3. package/AGENTS.md +348 -0
  4. package/LICENSE +21 -0
  5. package/README.md +249 -0
  6. package/SOUL.md +90 -0
  7. package/audits/agents-md.md +497 -0
  8. package/audits/agents-md.sh +353 -0
  9. package/audits/cross-tier-rates.sh +105 -0
  10. package/audits/data.sh +185 -0
  11. package/audits/deinit-pairs.sh +224 -0
  12. package/audits/design-tokens.sh +143 -0
  13. package/audits/doc-read.sh +183 -0
  14. package/audits/error-codes.sh +221 -0
  15. package/audits/fixtures/clean.diff +15 -0
  16. package/audits/fixtures/dirty.diff +16 -0
  17. package/audits/lifecycle-anchors.sh +22 -0
  18. package/audits/logging.sh +217 -0
  19. package/audits/msid-ui.sh +144 -0
  20. package/audits/parity-dispatch.sh +51 -0
  21. package/audits/rule-ledger-lib.sh +264 -0
  22. package/audits/rule-ledger.sh +225 -0
  23. package/audits/rule-paths.sh +108 -0
  24. package/audits/spec-template.sh +258 -0
  25. package/audits/sql-mod.sh +102 -0
  26. package/audits/ufs.sh +303 -0
  27. package/bin/orly +26 -0
  28. package/core/operating-model.md +256 -0
  29. package/dispatch/edit_rules.md +69 -0
  30. package/dispatch/lib.sh +213 -0
  31. package/dispatch/lifecycle.md +187 -0
  32. package/dispatch/name_architecture.md +93 -0
  33. package/dispatch/verify.md +111 -0
  34. package/dispatch/write_any.md +740 -0
  35. package/dispatch/write_any.sh +49 -0
  36. package/dispatch/write_auth.md +36 -0
  37. package/dispatch/write_changelog.md +64 -0
  38. package/dispatch/write_documentation.md +45 -0
  39. package/dispatch/write_go.md +24 -0
  40. package/dispatch/write_http.md +45 -0
  41. package/dispatch/write_mdx.md +7 -0
  42. package/dispatch/write_python.md +8 -0
  43. package/dispatch/write_rust.md +38 -0
  44. package/dispatch/write_shell.md +8 -0
  45. package/dispatch/write_spec.md +105 -0
  46. package/dispatch/write_sql.md +112 -0
  47. package/dispatch/write_sql.sh +36 -0
  48. package/dispatch/write_ts_adhere_bun.md +493 -0
  49. package/dispatch/write_ts_adhere_bun.sh +37 -0
  50. package/dispatch/write_zig.md +852 -0
  51. package/dispatch/write_zig.sh +40 -0
  52. package/docs/CHANGELOG_VOICE.md +18 -0
  53. package/docs/DISPATCH_ARCHITECTURE.md +664 -0
  54. package/docs/DISPATCH_REVIEW_DISPOSITION.md +65 -0
  55. package/docs/DOCUMENTATION_RULES.md +209 -0
  56. package/docs/EXECUTE_DOC_READS.md +29 -0
  57. package/docs/HARNESS_VERIFY_OUTPUT.md +40 -0
  58. package/docs/LIFECYCLE_PATTERNS.md +316 -0
  59. package/docs/LOGGING_STANDARD.md +397 -0
  60. package/docs/ORLY_ARCHITECTURE.md +129 -0
  61. package/docs/REST_API_DESIGN_GUIDELINES.md +783 -0
  62. package/docs/RULE_ENFORCEMENT.md +41 -0
  63. package/docs/SCHEMA_CONVENTIONS.md +93 -0
  64. package/docs/TEMPLATE.md +411 -0
  65. package/docs/VERIFY_TIERS.md +57 -0
  66. package/docs/greptile-learnings/README.md +51 -0
  67. package/docs/greptile-learnings/RULES.md +1045 -0
  68. package/fixtures/registry-invalid.json +1 -0
  69. package/fixtures/registry-valid.json +18 -0
  70. package/fixtures/unclosed-pack-block.md +8 -0
  71. package/package.json +63 -0
  72. package/packs/language/go/rules.md +24 -0
  73. package/packs/language/mdx/rules.md +7 -0
  74. package/packs/language/python/rules.md +8 -0
  75. package/packs/language/rust/rules.md +38 -0
  76. package/packs/language/shell/rules.md +8 -0
  77. package/registry.json +405 -0
  78. package/schemas/evidence.schema.json +43 -0
  79. package/schemas/profile.schema.json +12 -0
  80. package/schemas/registry.schema.json +12 -0
  81. package/skills/orly-babysit-prs/SKILL.md +326 -0
  82. package/skills/orly-spec-new/SKILL.md +157 -0
  83. package/skills/orly-write-integration-test/SKILL.md +470 -0
  84. package/skills/orly-write-unit-test/SKILL.md +520 -0
  85. package/src/cli.test.ts +70 -0
  86. package/src/cli.ts +287 -0
  87. package/src/config.test.ts +226 -0
  88. package/src/config.ts +288 -0
  89. package/src/criteria.test.ts +137 -0
  90. package/src/criteria.ts +166 -0
  91. package/src/criteria_spec.ts +127 -0
  92. package/src/criteria_support.ts +53 -0
  93. package/src/gates.test.ts +183 -0
  94. package/src/gates.ts +169 -0
  95. package/src/gates_spec.test.ts +98 -0
  96. package/src/gates_test_support.ts +194 -0
  97. package/src/git_env.ts +20 -0
  98. package/src/install.test.ts +514 -0
  99. package/src/install.ts +447 -0
  100. package/src/install_nested.test.ts +32 -0
  101. package/src/model.test.ts +31 -0
  102. package/src/model.ts +245 -0
  103. package/src/references.test.ts +47 -0
  104. package/src/references.ts +147 -0
  105. package/src/render.test.ts +60 -0
  106. package/src/render.ts +94 -0
  107. package/src/surfaces.test.ts +43 -0
  108. package/src/surfaces.ts +89 -0
  109. package/src/validation.test.ts +11 -0
  110. package/src/validation.ts +87 -0
  111. package/src/verify.test.ts +13 -0
  112. package/src/verify.ts +59 -0
@@ -0,0 +1,514 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { cleanupTemporaryDirectories, git, gitOutput, newRepository, ROOT } from "./gates_test_support";
7
+ import { install } from "./install";
8
+ import { readConfig } from "./config";
9
+ import { RulesModel } from "./model";
10
+
11
+ afterEach(cleanupTemporaryDirectories);
12
+
13
+
14
+ describe("install", () => {
15
+ test("materialises the packs its own sources imply, hooks, and a config into a fresh repository", async () => {
16
+ const model = await RulesModel.load(ROOT);
17
+ const repo = newRepository();
18
+ await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
19
+
20
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
21
+
22
+ expect(result.ok).toBe(true);
23
+ expect(result.packs).toContain("language.rust");
24
+ expect(result.packs).not.toContain("language.zig");
25
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(true);
26
+ expect(existsSync(join(repo, "dispatch/write_rust.md"))).toBe(true);
27
+ expect(existsSync(join(repo, ".githooks/pre-commit"))).toBe(true);
28
+ expect(existsSync(join(repo, ".oracle/orly.json"))).toBe(true);
29
+ });
30
+
31
+ test("a second install over the same target reports zero writes", async () => {
32
+ const model = await RulesModel.load(ROOT);
33
+ const repo = newRepository();
34
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
35
+
36
+ const second = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
37
+
38
+ expect(second.ok).toBe(true);
39
+ expect(second.written).toEqual([]);
40
+ expect(second.skipped.length).toBeGreaterThan(0);
41
+ });
42
+
43
+ test("replaces a file it wrote, hand-edited or not — git shows the replacement", async () => {
44
+ const model = await RulesModel.load(ROOT);
45
+ const repo = newRepository();
46
+ await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
47
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
48
+ await Bun.write(join(repo, "dispatch/write_rust.md"), "hand-edited\n");
49
+
50
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
51
+
52
+ expect(result.ok).toBe(true);
53
+ expect(result.written).toContain("dispatch/write_rust.md");
54
+ expect(await Bun.file(join(repo, "dispatch/write_rust.md")).text()).not.toBe("hand-edited\n");
55
+ });
56
+
57
+ // The other half of the same rule: authorship decides. A file orly never
58
+ // wrote is the repository's, and is never replaced without being asked twice.
59
+ test("refuses a file it never wrote, names it, and leaves the tree untouched", async () => {
60
+ const model = await RulesModel.load(ROOT);
61
+ const repo = newRepository();
62
+ await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
63
+ await Bun.write(join(repo, "dispatch/write_rust.md"), "the repository's own file\n");
64
+
65
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
66
+
67
+ expect(result.ok).toBe(false);
68
+ expect(result.errors.some((error) => error.path === "dispatch/write_rust.md")).toBeTrue();
69
+ expect(await Bun.file(join(repo, "dispatch/write_rust.md")).text()).toBe("the repository's own file\n");
70
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
71
+ });
72
+
73
+ test("--force overwrites a hand-edited managed file", async () => {
74
+ const model = await RulesModel.load(ROOT);
75
+ const repo = newRepository();
76
+ await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
77
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
78
+ await Bun.write(join(repo, "dispatch/write_rust.md"), "hand-edited\n");
79
+
80
+ const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
81
+
82
+ expect(result.ok).toBe(true);
83
+ expect(await Bun.file(join(repo, "dispatch/write_rust.md")).text()).not.toBe("hand-edited\n");
84
+ });
85
+
86
+ test("rejects a target that is not a git repository, naming the fix", async () => {
87
+ const model = await RulesModel.load(ROOT);
88
+ const notARepo = mkdtempSync(join(tmpdir(), "orly-install-not-a-repo-"));
89
+ try {
90
+ expect(install(model, { targetRoot: notARepo, force: false, installHooks: true, orlyVersion: "0.4.0" })).rejects.toThrow("git init");
91
+ } finally {
92
+ rmSync(notARepo, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("rejects a target directory that does not exist at all", async () => {
97
+ const model = await RulesModel.load(ROOT);
98
+ expect(install(model, { targetRoot: join(tmpdir(), "orly-install-never-created"), force: false, installHooks: true, orlyVersion: "0.4.0" })).rejects.toThrow("does not exist");
99
+ });
100
+
101
+ test("rejects an unknown pack named by the repository's own config, before writing anything", async () => {
102
+ const model = await RulesModel.load(ROOT);
103
+ const repo = newRepository();
104
+ await Bun.write(join(repo, ".oracle/orly.json"), JSON.stringify({ schema_version: 1, packs: ["language.cobol"], commands: {} }));
105
+
106
+ expect(install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" })).rejects.toThrow("unknown pack");
107
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
108
+ });
109
+
110
+ test("sets core.hooksPath and installs both hooks with the git-scope-unsetting preamble", async () => {
111
+ const model = await RulesModel.load(ROOT);
112
+ const repo = newRepository();
113
+
114
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
115
+
116
+ expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe(".githooks");
117
+ for (const hook of ["pre-commit", "pre-push"]) {
118
+ const text = await Bun.file(join(repo, ".githooks", hook)).text();
119
+ expect(text).toContain("GIT_DIR");
120
+ }
121
+ });
122
+
123
+ test("--no-hooks skips hook installation and leaves core.hooksPath unset", async () => {
124
+ const model = await RulesModel.load(ROOT);
125
+ const repo = newRepository();
126
+
127
+ await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
128
+
129
+ expect(existsSync(join(repo, ".githooks"))).toBe(false);
130
+ expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe("");
131
+ });
132
+
133
+ test("an existing AGENTS.md survives byte for byte and orly lands beside it", async () => {
134
+ const model = await RulesModel.load(ROOT);
135
+ const repo = newRepository();
136
+ const theirs = "# Our house rules\n\nAlways run `go vet`. Never touch vendor/.\n";
137
+ await Bun.write(join(repo, "AGENTS.md"), theirs);
138
+ await Bun.write(join(repo, "m.go"), "package main\n");
139
+
140
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
141
+
142
+ expect(result.ok).toBe(true);
143
+ expect(existsSync(join(repo, "AGENTS.orly.md"))).toBe(true);
144
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
145
+ expect(host).toStartWith(theirs.trimEnd());
146
+ expect(host).toContain("AGENTS.orly.md");
147
+ // Their file is theirs: orly owns only the block, never the whole file.
148
+ expect((await readConfig(repo))?.managed).not.toContain("AGENTS.md");
149
+ });
150
+
151
+ test("the orly pointer block is idempotent across repeated installs", async () => {
152
+ const model = await RulesModel.load(ROOT);
153
+ const repo = newRepository();
154
+ await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
155
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
156
+ const afterFirst = await Bun.file(join(repo, "AGENTS.md")).text();
157
+
158
+ const second = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
159
+
160
+ expect(second.ok).toBe(true);
161
+ expect(await Bun.file(join(repo, "AGENTS.md")).text()).toBe(afterFirst);
162
+ expect(afterFirst.split("<!-- orly:begin -->").length - 1).toBe(1);
163
+ });
164
+
165
+ // One layout everywhere, so `update` never asks which mode it is in and a
166
+ // repository that starts with no rules still has the file to put them in.
167
+ test("a repository with no AGENTS.md still gets both files", async () => {
168
+ const model = await RulesModel.load(ROOT);
169
+ const repo = newRepository();
170
+
171
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
172
+
173
+ expect(await Bun.file(join(repo, "AGENTS.orly.md")).text()).toStartWith("> **Generated by `orly`.**");
174
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
175
+ expect(host).toContain("AGENTS.orly.md");
176
+ expect(host).toContain("Write this repository's own rules below");
177
+ expect((await readConfig(repo))?.managed).not.toContain("AGENTS.md");
178
+ });
179
+
180
+ // Anything installed before the split holds orly's render under AGENTS.md.
181
+ // That content is generated, so replacing it with the stub loses nothing.
182
+ test("a single-file install migrates to the split without losing a repository line", async () => {
183
+ const model = await RulesModel.load(ROOT);
184
+ const repo = newRepository();
185
+ await Bun.write(join(repo, "AGENTS.md"), "> **Generated by `orly`.**\nold single-file layout\n");
186
+
187
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
188
+
189
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
190
+ expect(host).not.toContain("old single-file layout");
191
+ expect(host).toContain("AGENTS.orly.md");
192
+ expect(await Bun.file(join(repo, "AGENTS.orly.md")).text()).toStartWith("> **Generated by `orly`.**");
193
+ });
194
+
195
+ // Rule pages cite `AGENTS.md` meaning orly's rules — true only where they are
196
+ // authored. In a consumer that citation has to name the file orly wrote.
197
+ test("rule pages cite the file orly actually wrote, not the repository's", async () => {
198
+ const model = await RulesModel.load(ROOT);
199
+ const repo = newRepository();
200
+ await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
201
+
202
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
203
+
204
+ expect(result.ok).toBe(true);
205
+ const rules = await Bun.file(join(repo, "docs/DOCUMENTATION_RULES.md")).text();
206
+ expect(rules).not.toContain("](../AGENTS.md)");
207
+ });
208
+
209
+ test("update rewrites orly's file and leaves the repository's own alone", async () => {
210
+ const model = await RulesModel.load(ROOT);
211
+ const repo = newRepository();
212
+ await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
213
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
214
+ await Bun.write(join(repo, "AGENTS.orly.md"), "hand-edited\n");
215
+ const host = await Bun.file(join(repo, "AGENTS.md")).text();
216
+
217
+ const second = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
218
+
219
+ expect(second.ok).toBe(true);
220
+ expect(second.written).toContain("AGENTS.orly.md");
221
+ expect(await Bun.file(join(repo, "AGENTS.orly.md")).text()).toStartWith("> **Generated by `orly`.**");
222
+ expect(await Bun.file(join(repo, "AGENTS.md")).text()).toBe(host);
223
+ });
224
+
225
+ test("a hook orly did not write is refused, not clobbered", async () => {
226
+ const model = await RulesModel.load(ROOT);
227
+ const repo = newRepository();
228
+ const theirs = "#!/usr/bin/env bash\necho 'our precious hook'\n";
229
+ await Bun.write(join(repo, ".githooks/pre-commit"), theirs);
230
+
231
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
232
+
233
+ expect(result.ok).toBe(false);
234
+ expect(result.errors.map((error) => error.path)).toContain(".githooks/pre-commit");
235
+ expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toBe(theirs);
236
+ // A refusal leaves no footprint: the managed files never landed either.
237
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
238
+ });
239
+
240
+ test("--force replaces a hook orly did not write", async () => {
241
+ const model = await RulesModel.load(ROOT);
242
+ const repo = newRepository();
243
+ await Bun.write(join(repo, ".githooks/pre-commit"), "#!/usr/bin/env bash\necho 'ours'\n");
244
+
245
+ const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
246
+
247
+ expect(result.ok).toBe(true);
248
+ expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toContain("orly gate work");
249
+ });
250
+
251
+ test("--no-hooks installs the rules over an existing hook without touching it", async () => {
252
+ const model = await RulesModel.load(ROOT);
253
+ const repo = newRepository();
254
+ const theirs = "#!/usr/bin/env bash\necho 'ours'\n";
255
+ await Bun.write(join(repo, ".githooks/pre-commit"), theirs);
256
+
257
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
258
+
259
+ expect(result.ok).toBe(true);
260
+ expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toBe(theirs);
261
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(true);
262
+ });
263
+
264
+ test("refuses to retarget a hooksPath already claimed by something else", async () => {
265
+ const model = await RulesModel.load(ROOT);
266
+ const repo = newRepository();
267
+ git(repo, "config", "core.hooksPath", "some-other-tools-hooks");
268
+
269
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
270
+
271
+ expect(result.ok).toBe(false);
272
+ expect(result.errors.some((error) => error.path === "core.hooksPath")).toBe(true);
273
+ expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe("some-other-tools-hooks");
274
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
275
+ });
276
+
277
+ test("--no-hooks proceeds even when hooksPath is claimed by something else", async () => {
278
+ const model = await RulesModel.load(ROOT);
279
+ const repo = newRepository();
280
+ git(repo, "config", "core.hooksPath", "some-other-tools-hooks");
281
+
282
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
283
+
284
+ expect(result.ok).toBe(true);
285
+ expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe("some-other-tools-hooks");
286
+ });
287
+
288
+ test("--force retargets a hooksPath claimed by something else", async () => {
289
+ const model = await RulesModel.load(ROOT);
290
+ const repo = newRepository();
291
+ git(repo, "config", "core.hooksPath", "some-other-tools-hooks");
292
+
293
+ const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
294
+
295
+ expect(result.ok).toBe(true);
296
+ expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe(".githooks");
297
+ });
298
+
299
+ test("re-running init after it already set hooksPath is not a claim by another tool", async () => {
300
+ const model = await RulesModel.load(ROOT);
301
+ const repo = newRepository();
302
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
303
+
304
+ const second = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
305
+
306
+ expect(second.ok).toBe(true);
307
+ });
308
+
309
+ // Adversarial review, reproduced: a repository the user cloned can commit a
310
+ // symlink at a path init would otherwise write to. mkdirSync/writeFile/
311
+ // rename all follow an existing symlink silently — without this refusal,
312
+ // every managed file materialises through it into wherever the symlink
313
+ // points, outside the repository entirely, with `ok: true` and no warning.
314
+ // Adversarial review found this: every other write was guarded, the pointer
315
+ // host was not, and it is the one write orly aims at a file it does not own.
316
+ // A committed `AGENTS.md -> ../outside/victim` carried the block out of the
317
+ // repository and reported a normal success.
318
+ test("refuses when the pointer host is a symlink escaping the target repository", async () => {
319
+ const model = await RulesModel.load(ROOT);
320
+ const repo = newRepository();
321
+ const outside = mkdtempSync(join(tmpdir(), "orly-install-outside-"));
322
+ try {
323
+ const victim = join(outside, "victim.md");
324
+ await Bun.write(victim, "PRIVATE\n");
325
+ symlinkSync(victim, join(repo, "AGENTS.md"));
326
+
327
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
328
+
329
+ expect(result.ok).toBe(false);
330
+ expect(result.errors.some((error) => error.message.includes("outside the target repository"))).toBe(true);
331
+ expect(await Bun.file(victim).text()).toBe("PRIVATE\n");
332
+ expect(existsSync(join(repo, "AGENTS.orly.md"))).toBe(false);
333
+ } finally {
334
+ rmSync(outside, { recursive: true, force: true });
335
+ }
336
+ });
337
+
338
+ // assertWritableInside validated only the parent directory, so a symlinked
339
+ // *file* passed: its ancestor is squarely inside the repository. --force
340
+ // skips the authorship check, which was the only thing catching it.
341
+ test("--force still refuses a hook that is a symlink out of the repository", async () => {
342
+ const model = await RulesModel.load(ROOT);
343
+ const repo = newRepository();
344
+ const outside = mkdtempSync(join(tmpdir(), "orly-install-outside-"));
345
+ try {
346
+ const victim = join(outside, "hookvictim");
347
+ await Bun.write(victim, "VICTIM\n");
348
+ mkdirSync(join(repo, ".githooks"), { recursive: true });
349
+ symlinkSync(victim, join(repo, ".githooks/pre-commit"));
350
+
351
+ const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
352
+
353
+ expect(result.ok).toBe(false);
354
+ expect(await Bun.file(victim).text()).toBe("VICTIM\n");
355
+ } finally {
356
+ rmSync(outside, { recursive: true, force: true });
357
+ }
358
+ });
359
+
360
+ test("refuses when a managed file's path is a symlink escaping the target repository", async () => {
361
+ const model = await RulesModel.load(ROOT);
362
+ const repo = newRepository();
363
+ const outside = mkdtempSync(join(tmpdir(), "orly-install-outside-"));
364
+ try {
365
+ symlinkSync(outside, join(repo, "dispatch"));
366
+
367
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
368
+
369
+ expect(result.ok).toBe(false);
370
+ expect(result.errors.some((error) => error.message.includes("outside the target repository"))).toBe(true);
371
+ expect(existsSync(join(outside, "write_rust.md"))).toBe(false);
372
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
373
+ } finally {
374
+ rmSync(outside, { recursive: true, force: true });
375
+ }
376
+ });
377
+
378
+ test("refuses when the hooks directory path is a symlink escaping the target repository", async () => {
379
+ const model = await RulesModel.load(ROOT);
380
+ const repo = newRepository();
381
+ const outside = mkdtempSync(join(tmpdir(), "orly-install-outside-hooks-"));
382
+ try {
383
+ symlinkSync(outside, join(repo, ".githooks"));
384
+
385
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
386
+
387
+ expect(result.ok).toBe(false);
388
+ expect(result.errors.some((error) => error.message.includes("outside the target repository"))).toBe(true);
389
+ expect(existsSync(join(outside, "pre-commit"))).toBe(false);
390
+ } finally {
391
+ rmSync(outside, { recursive: true, force: true });
392
+ }
393
+ });
394
+
395
+ test("refuses when the .oracle lock directory path is a symlink escaping the target repository", async () => {
396
+ const model = await RulesModel.load(ROOT);
397
+ const repo = newRepository();
398
+ const outside = mkdtempSync(join(tmpdir(), "orly-install-outside-oracle-"));
399
+ try {
400
+ symlinkSync(outside, join(repo, ".oracle"));
401
+
402
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
403
+
404
+ expect(result.ok).toBe(false);
405
+ expect(result.errors.some((error) => error.message.includes("outside the target repository"))).toBe(true);
406
+ expect(existsSync(join(outside, "orly.json"))).toBe(false);
407
+ } finally {
408
+ rmSync(outside, { recursive: true, force: true });
409
+ }
410
+ });
411
+
412
+ // Adversarial review, reproduced with a real cross-filesystem mount: staging
413
+ // in the OS tmp dir means `rename(2)` can be asked to cross a filesystem
414
+ // boundary, which it cannot do — every install against a target on a
415
+ // different filesystem than $TMPDIR (an external drive, a devcontainer's
416
+ // bind-mounted workspace) hard-crashed with EXDEV. Staging inside the
417
+ // target's own .oracle/ makes that structurally impossible to reintroduce:
418
+ // assert no stage directory or leftover ever appears outside the target.
419
+ test("stages inside the target repository, not the OS tmp dir", async () => {
420
+ const model = await RulesModel.load(ROOT);
421
+ const repo = newRepository();
422
+ const tmpBefore = readdirSync(tmpdir()).filter((name) => name.startsWith("orly-install-"));
423
+
424
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
425
+
426
+ expect(result.ok).toBe(true);
427
+ const tmpAfter = readdirSync(tmpdir()).filter((name) => name.startsWith("orly-install-"));
428
+ expect(tmpAfter).toEqual(tmpBefore);
429
+ });
430
+
431
+ // The bug this milestone's own atomicity test caught while fixing the
432
+ // above: cleaning up an empty .oracle/ on a refused install must not also
433
+ // fire on the success path, where .oracle/ is legitimately empty for one
434
+ // instant before the caller writes orly.json into it.
435
+ test("a successful install leaves .oracle/ intact for the lock the caller writes next", async () => {
436
+ const model = await RulesModel.load(ROOT);
437
+ const repo = newRepository();
438
+
439
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
440
+
441
+ expect(result.ok).toBe(true);
442
+ expect(existsSync(join(repo, ".oracle", "orly.json"))).toBe(true);
443
+ });
444
+
445
+ test("the written config records the engine version and every materialised file", async () => {
446
+ const model = await RulesModel.load(ROOT);
447
+ const repo = newRepository();
448
+ await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
449
+
450
+ await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
451
+
452
+ const config = await readConfig(repo);
453
+ expect(config).toBeDefined();
454
+ expect(config?.orly_version).toBe("0.4.0");
455
+ expect(config?.managed).toContain("dispatch/write_rust.md");
456
+ expect(config?.managed).toContain(".githooks/pre-commit");
457
+ });
458
+
459
+ test("refuses atomically when a selected pack's file cites a façade no selected pack provides", async () => {
460
+ // The §4 registry defects (dispatch/lifecycle.md owned by no pack;
461
+ // dispatch/name_architecture.md behind a pack universal prose didn't
462
+ // select) were exactly this shape: a synthetic engine root with one pack
463
+ // whose only managed file cites a dispatch/ path nothing installs.
464
+ const engineRoot = mkdtempSync(join(tmpdir(), "orly-install-broken-engine-"));
465
+ try {
466
+ await Bun.write(join(engineRoot, "core.md"), "core\n");
467
+ await Bun.write(join(engineRoot, "broken.md"), "cites dispatch/missing.md\n");
468
+
469
+ const registry = {
470
+ schema_version: 1,
471
+ core_documents: ["core.md"],
472
+ packs: { broken: { extensions: [], managed_files: [{ source: "broken.md", target: "broken.md" }] } },
473
+ rules: [],
474
+ };
475
+ const model = new RulesModel(engineRoot, registry);
476
+ const repo = newRepository();
477
+
478
+ const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
479
+
480
+ expect(result.ok).toBe(false);
481
+ expect(result.errors[0]?.message).toContain("missing dispatch reference");
482
+ expect(existsSync(join(repo, "broken.md"))).toBe(false);
483
+ expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
484
+ expect(existsSync(join(repo, ".oracle"))).toBe(false);
485
+ } finally {
486
+ rmSync(engineRoot, { recursive: true, force: true });
487
+ }
488
+ });
489
+
490
+ test("two packs disagreeing on one target's source is a registry error, not a silent pick", async () => {
491
+ const engineRoot = mkdtempSync(join(tmpdir(), "orly-install-conflict-engine-"));
492
+ try {
493
+ await Bun.write(join(engineRoot, "core.md"), "core\n");
494
+ await Bun.write(join(engineRoot, "a.md"), "from pack a\n");
495
+ await Bun.write(join(engineRoot, "b.md"), "from pack b\n");
496
+
497
+ const registry = {
498
+ schema_version: 1,
499
+ core_documents: ["core.md"],
500
+ packs: {
501
+ a: { extensions: [], managed_files: [{ source: "a.md", target: "shared.md" }] },
502
+ b: { extensions: [], managed_files: [{ source: "b.md", target: "shared.md" }] },
503
+ },
504
+ rules: [],
505
+ };
506
+ const model = new RulesModel(engineRoot, registry);
507
+ const repo = newRepository();
508
+
509
+ expect(install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" })).rejects.toThrow("packs disagree on shared.md");
510
+ } finally {
511
+ rmSync(engineRoot, { recursive: true, force: true });
512
+ }
513
+ });
514
+ });