@agentsfleet/orly 0.10.14 → 0.11.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/.githooks/pre-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +65 -29
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +12 -5
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +153 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +37 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +9 -6
- package/src/gates_spec.test.ts +52 -6
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
|
@@ -0,0 +1,203 @@
|
|
|
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, resolve } 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("installation loaders and hooks", () => {
|
|
15
|
+
test("an existing AGENTS.md survives byte for byte and orly lands beside it", async () => {
|
|
16
|
+
const model = await RulesModel.load(ROOT);
|
|
17
|
+
const repo = newRepository();
|
|
18
|
+
const theirs = "# Our house rules\n\nAlways run `go vet`. Never touch vendor/.\n";
|
|
19
|
+
await Bun.write(join(repo, "AGENTS.md"), theirs);
|
|
20
|
+
await Bun.write(join(repo, "m.go"), "package main\n");
|
|
21
|
+
|
|
22
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
23
|
+
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
expect(existsSync(join(repo, ".orly/AGENTS.md"))).toBe(true);
|
|
26
|
+
const host = await Bun.file(join(repo, "AGENTS.md")).text();
|
|
27
|
+
expect(host).toStartWith(theirs.trimEnd());
|
|
28
|
+
expect(host).toContain(".orly/AGENTS.md");
|
|
29
|
+
// Their file is theirs: orly owns only the block, never the whole file.
|
|
30
|
+
expect((await readConfig(repo))?.managed).not.toContain("AGENTS.md");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the orly pointer block is idempotent across repeated installs", async () => {
|
|
34
|
+
const model = await RulesModel.load(ROOT);
|
|
35
|
+
const repo = newRepository();
|
|
36
|
+
await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
|
|
37
|
+
await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
38
|
+
const afterFirst = await Bun.file(join(repo, "AGENTS.md")).text();
|
|
39
|
+
|
|
40
|
+
const second = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
41
|
+
|
|
42
|
+
expect(second.ok).toBe(true);
|
|
43
|
+
expect(await Bun.file(join(repo, "AGENTS.md")).text()).toBe(afterFirst);
|
|
44
|
+
expect(afterFirst.split("<!-- orly:begin -->").length - 1).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// One layout everywhere, so `update` never asks which mode it is in and a
|
|
48
|
+
// repository that starts with no rules still has the file to put them in.
|
|
49
|
+
test("a repository with no AGENTS.md still gets both files", async () => {
|
|
50
|
+
const model = await RulesModel.load(ROOT);
|
|
51
|
+
const repo = newRepository();
|
|
52
|
+
|
|
53
|
+
await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
54
|
+
|
|
55
|
+
expect(await Bun.file(join(repo, ".orly/AGENTS.md")).text()).toStartWith("> **Generated by `orly`.**");
|
|
56
|
+
const host = await Bun.file(join(repo, "AGENTS.md")).text();
|
|
57
|
+
expect(host).toContain(".orly/AGENTS.md");
|
|
58
|
+
expect(host).toContain("Write this repository's own rules below");
|
|
59
|
+
expect((await readConfig(repo))?.managed).not.toContain("AGENTS.md");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Anything installed before the split holds orly's render under AGENTS.md.
|
|
63
|
+
// That content is generated, so replacing it with the stub loses nothing.
|
|
64
|
+
test("an unrecorded generated banner cannot authorize deleting repository content", async () => {
|
|
65
|
+
const model = await RulesModel.load(ROOT);
|
|
66
|
+
const repo = newRepository();
|
|
67
|
+
await Bun.write(join(repo, "AGENTS.md"), "> **Generated by `orly`.**\nold single-file layout\n");
|
|
68
|
+
|
|
69
|
+
await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
70
|
+
|
|
71
|
+
const host = await Bun.file(join(repo, "AGENTS.md")).text();
|
|
72
|
+
expect(host).toContain("old single-file layout");
|
|
73
|
+
expect(host).toContain(".orly/AGENTS.md");
|
|
74
|
+
expect(await Bun.file(join(repo, ".orly/AGENTS.md")).text()).toStartWith("> **Generated by `orly`.**");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Rule pages cite `AGENTS.md` meaning orly's rules — true only where they are
|
|
78
|
+
// authored. In a consumer that citation has to name the file orly wrote.
|
|
79
|
+
test("rule pages cite the file orly actually wrote, not the repository's", async () => {
|
|
80
|
+
const model = await RulesModel.load(ROOT);
|
|
81
|
+
const repo = newRepository();
|
|
82
|
+
await Bun.write(join(repo, "src/lib.rs"), "pub fn main() {}\n");
|
|
83
|
+
|
|
84
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
85
|
+
|
|
86
|
+
expect(result.ok).toBe(true);
|
|
87
|
+
const rules = await Bun.file(join(repo, ".orly/docs/DOCUMENTATION_RULES.md")).text();
|
|
88
|
+
const parent = rules.match(/Parent:.*?\]\(([^)]+)\)/)?.[1];
|
|
89
|
+
expect(parent).toBeDefined();
|
|
90
|
+
expect(resolve(repo, ".orly/docs", parent!)).toBe(resolve(repo, ".orly/AGENTS.md"));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("update rewrites orly's file and leaves the repository's own alone", async () => {
|
|
94
|
+
const model = await RulesModel.load(ROOT);
|
|
95
|
+
const repo = newRepository();
|
|
96
|
+
await Bun.write(join(repo, "AGENTS.md"), "# Ours\n");
|
|
97
|
+
await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
|
|
98
|
+
await Bun.write(join(repo, ".orly/AGENTS.md"), "hand-edited\n");
|
|
99
|
+
const host = await Bun.file(join(repo, "AGENTS.md")).text();
|
|
100
|
+
|
|
101
|
+
const second = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
|
|
102
|
+
|
|
103
|
+
expect(second.ok).toBe(true);
|
|
104
|
+
expect(second.written).toContain(".orly/AGENTS.md");
|
|
105
|
+
expect(await Bun.file(join(repo, ".orly/AGENTS.md")).text()).toStartWith("> **Generated by `orly`.**");
|
|
106
|
+
expect(await Bun.file(join(repo, "AGENTS.md")).text()).toBe(host);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a hook orly did not write is refused, not clobbered", async () => {
|
|
110
|
+
const model = await RulesModel.load(ROOT);
|
|
111
|
+
const repo = newRepository();
|
|
112
|
+
const theirs = "#!/usr/bin/env bash\necho 'our precious hook'\n";
|
|
113
|
+
await Bun.write(join(repo, ".orly/hooks/pre-commit"), theirs);
|
|
114
|
+
|
|
115
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
116
|
+
|
|
117
|
+
expect(result.ok).toBe(false);
|
|
118
|
+
expect(result.errors.map((error) => error.path)).toContain(".orly/hooks/pre-commit");
|
|
119
|
+
expect(await Bun.file(join(repo, ".orly/hooks/pre-commit")).text()).toBe(theirs);
|
|
120
|
+
// A refusal leaves no footprint: the managed files never landed either.
|
|
121
|
+
expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("generated_hooks_mark_telemetry_invocation", async () => {
|
|
125
|
+
const model = await RulesModel.load(ROOT);
|
|
126
|
+
const repo = newRepository();
|
|
127
|
+
await Bun.write(join(repo, ".orly/hooks/pre-commit"), "#!/usr/bin/env bash\necho 'ours'\n");
|
|
128
|
+
|
|
129
|
+
const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
|
|
130
|
+
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
expect(await Bun.file(join(repo, ".orly/hooks/pre-commit")).text()).toContain("export ORLY_INVOCATION=hook");
|
|
133
|
+
expect(await Bun.file(join(repo, ".orly/hooks/pre-push")).text()).toContain("export ORLY_INVOCATION=hook");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("--no-hooks installs the rules over an existing hook without touching it", async () => {
|
|
137
|
+
const model = await RulesModel.load(ROOT);
|
|
138
|
+
const repo = newRepository();
|
|
139
|
+
const theirs = "#!/usr/bin/env bash\necho 'ours'\n";
|
|
140
|
+
await Bun.write(join(repo, ".orly/hooks/pre-commit"), theirs);
|
|
141
|
+
|
|
142
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
expect(await Bun.file(join(repo, ".orly/hooks/pre-commit")).text()).toBe(theirs);
|
|
146
|
+
expect(existsSync(join(repo, "AGENTS.md"))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("refuses to retarget a hooksPath already claimed by something else", async () => {
|
|
150
|
+
const model = await RulesModel.load(ROOT);
|
|
151
|
+
const repo = newRepository();
|
|
152
|
+
git(repo, "config", "core.hooksPath", "some-other-tools-hooks");
|
|
153
|
+
|
|
154
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
155
|
+
|
|
156
|
+
expect(result.ok).toBe(false);
|
|
157
|
+
expect(result.errors.some((error) => error.path === "core.hooksPath")).toBe(true);
|
|
158
|
+
expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe("some-other-tools-hooks");
|
|
159
|
+
expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("--no-hooks proceeds even when hooksPath is claimed by something else", async () => {
|
|
163
|
+
const model = await RulesModel.load(ROOT);
|
|
164
|
+
const repo = newRepository();
|
|
165
|
+
git(repo, "config", "core.hooksPath", "some-other-tools-hooks");
|
|
166
|
+
|
|
167
|
+
const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.4.0" });
|
|
168
|
+
|
|
169
|
+
expect(result.ok).toBe(true);
|
|
170
|
+
expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe("some-other-tools-hooks");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("--force retargets a hooksPath claimed by something else", async () => {
|
|
174
|
+
const model = await RulesModel.load(ROOT);
|
|
175
|
+
const repo = newRepository();
|
|
176
|
+
git(repo, "config", "core.hooksPath", "some-other-tools-hooks");
|
|
177
|
+
|
|
178
|
+
const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
|
|
179
|
+
|
|
180
|
+
expect(result.ok).toBe(true);
|
|
181
|
+
expect(gitOutput(repo, "config", "--get", "core.hooksPath")).toBe(".orly/hooks");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("re-running init after it already set hooksPath is not a claim by another tool", async () => {
|
|
185
|
+
const model = await RulesModel.load(ROOT);
|
|
186
|
+
const repo = newRepository();
|
|
187
|
+
await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
188
|
+
|
|
189
|
+
const second = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: "0.4.0" });
|
|
190
|
+
|
|
191
|
+
expect(second.ok).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Adversarial review, reproduced: a repository the user cloned can commit a
|
|
195
|
+
// symlink at a path init would otherwise write to. mkdirSync/writeFile/
|
|
196
|
+
// rename all follow an existing symlink silently — without this refusal,
|
|
197
|
+
// every managed file materialises through it into wherever the symlink
|
|
198
|
+
// points, outside the repository entirely, with `ok: true` and no warning.
|
|
199
|
+
// Adversarial review found this: every other write was guarded, the pointer
|
|
200
|
+
// host was not, and it is the one write orly aims at a file it does not own.
|
|
201
|
+
// A committed `AGENTS.md -> ../outside/victim` carried the block out of the
|
|
202
|
+
// repository and reported a normal success.
|
|
203
|
+
});
|
|
@@ -26,7 +26,7 @@ describe("install package location", () => {
|
|
|
26
26
|
const result = await install(model, { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.5.0" });
|
|
27
27
|
|
|
28
28
|
expect(result.ok).toBe(true);
|
|
29
|
-
expect(result.written).toContain("rules.md");
|
|
30
|
-
expect(await Bun.file(join(repo, "rules.md")).text()).toBe("fixture rules\n");
|
|
29
|
+
expect(result.written).toContain(".orly/rules.md");
|
|
30
|
+
expect(await Bun.file(join(repo, ".orly/rules.md")).text()).toBe("fixture rules\n");
|
|
31
31
|
});
|
|
32
32
|
});
|
|
@@ -8,8 +8,8 @@ import { RulesModel } from "./model";
|
|
|
8
8
|
|
|
9
9
|
const SOURCE_EXTENSIONS = ["rs", "ts", "tsx", "js", "jsx", "py", "sh", "sql", "zig", "mdx"];
|
|
10
10
|
const DESCRIPTION_BUDGET = 320;
|
|
11
|
-
const RECORDER = "audits/doc-read.sh";
|
|
12
|
-
const RECORDER_LIBRARY = "audits/rule-ledger-lib.sh";
|
|
11
|
+
const RECORDER = ".orly/audits/doc-read.sh";
|
|
12
|
+
const RECORDER_LIBRARY = ".orly/audits/rule-ledger-lib.sh";
|
|
13
13
|
// Read from package.json rather than restated here: a hand-synced copy goes
|
|
14
14
|
// stale at the next release and the test then proves an install at a version
|
|
15
15
|
// that no longer ships.
|
|
@@ -18,6 +18,66 @@ const ENGINE_VERSION = (await Bun.file(join(ROOT, "package.json")).json()).versi
|
|
|
18
18
|
afterEach(cleanupTemporaryDirectories);
|
|
19
19
|
|
|
20
20
|
describe("opt-in pack selection", () => {
|
|
21
|
+
test("independent consumers keep their own database, build and documentation policies", async () => {
|
|
22
|
+
const model = await RulesModel.load(ROOT);
|
|
23
|
+
const repo = newRepository();
|
|
24
|
+
for (const extension of ["sql", "zig", "ts"]) {
|
|
25
|
+
await Bun.write(join(repo, `src/source.${extension}`), "\n");
|
|
26
|
+
}
|
|
27
|
+
await Bun.write(join(repo, ".orly/orly.json"), JSON.stringify({
|
|
28
|
+
schema_version: 1, packs: ["domain.http", "domain.changelog"],
|
|
29
|
+
commands: { conform: [["true"]], "verify.unit": [["true"]] },
|
|
30
|
+
}));
|
|
31
|
+
const result = await install(model, {
|
|
32
|
+
targetRoot: repo, force: false, installHooks: false, orlyVersion: ENGINE_VERSION,
|
|
33
|
+
});
|
|
34
|
+
expect(result.errors).toEqual([]);
|
|
35
|
+
expect(result.packs).not.toContain("product.agentsfleet");
|
|
36
|
+
expect(existsSync(join(repo, "VERSION"))).toBe(false);
|
|
37
|
+
const pages = [
|
|
38
|
+
"dispatch/lifecycle.md", "dispatch/write_sql.md", "dispatch/write_zig.md",
|
|
39
|
+
"dispatch/write_ts_adhere_bun.md", "dispatch/write_http.md",
|
|
40
|
+
"dispatch/write_changelog.md", "docs/SCHEMA_CONVENTIONS.md",
|
|
41
|
+
"docs/RELEASE_TEMPLATE.md", "docs/LOGGING_STANDARD.md",
|
|
42
|
+
"docs/REST_API_DESIGN_GUIDELINES.md",
|
|
43
|
+
];
|
|
44
|
+
const installed = await Promise.all(pages.map((path) => Bun.file(join(repo, ".orly", path)).text()));
|
|
45
|
+
for (const content of installed) {
|
|
46
|
+
expect(content).not.toContain("playbooks/founding/");
|
|
47
|
+
expect(content).not.toContain("schema/embed.zig");
|
|
48
|
+
expect(content).not.toContain("src/cmd/common.zig");
|
|
49
|
+
expect(content).not.toContain("build_runner.zig");
|
|
50
|
+
expect(content).not.toContain("~/Projects/docs/");
|
|
51
|
+
expect(content).not.toContain("cat VERSION");
|
|
52
|
+
}
|
|
53
|
+
expect(installed[1]).toContain("Destructive changes require the owner's approval");
|
|
54
|
+
expect(installed[2]).toContain("declared platform matrix");
|
|
55
|
+
expect(installed[3]).toContain("repository's declared component library");
|
|
56
|
+
expect(installed[8]).toContain("repository's structured logger");
|
|
57
|
+
expect(installed[9]).toContain("canonical OpenAPI source");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("explicit product selection retains its teardown guard and build graph", async () => {
|
|
61
|
+
const model = await RulesModel.load(ROOT);
|
|
62
|
+
const repo = newRepository();
|
|
63
|
+
await Bun.write(join(repo, "schema/example.sql"), "\n");
|
|
64
|
+
await Bun.write(join(repo, "src/example.zig"), "\n");
|
|
65
|
+
await Bun.write(join(repo, ".orly/orly.json"), JSON.stringify({
|
|
66
|
+
schema_version: 1, packs: ["product.agentsfleet"],
|
|
67
|
+
commands: { conform: [["true"]], "verify.unit": [["true"]] },
|
|
68
|
+
}));
|
|
69
|
+
const result = await install(model, {
|
|
70
|
+
targetRoot: repo, force: false, installHooks: false, orlyVersion: ENGINE_VERSION,
|
|
71
|
+
});
|
|
72
|
+
expect(result.errors).toEqual([]);
|
|
73
|
+
const sql = await Bun.file(join(repo, ".orly/dispatch/write_sql.md")).text();
|
|
74
|
+
const zig = await Bun.file(join(repo, ".orly/dispatch/write_zig.md")).text();
|
|
75
|
+
expect(sql).toContain("0.30.0");
|
|
76
|
+
expect(sql).toContain("schema/embed.zig");
|
|
77
|
+
expect(sql).toContain("**Forbidden:**");
|
|
78
|
+
expect(zig).toContain("build_runner.zig");
|
|
79
|
+
});
|
|
80
|
+
|
|
21
81
|
// An opt-in pack may cite a file another opt-in pack owns, and every such
|
|
22
82
|
// citation has to be gated by the pack that provides it. Ungated, the install
|
|
23
83
|
// is refused for skipping rules the repository was never meant to take:
|
|
@@ -27,7 +87,7 @@ describe("opt-in pack selection", () => {
|
|
|
27
87
|
const model = await RulesModel.load(ROOT);
|
|
28
88
|
const repo = newRepository();
|
|
29
89
|
for (const extension of SOURCE_EXTENSIONS) await Bun.write(join(repo, `src/source.${extension}`), "\n");
|
|
30
|
-
await Bun.write(join(repo, ".
|
|
90
|
+
await Bun.write(join(repo, ".orly/orly.json"), JSON.stringify({ schema_version: 1, orly_version: "", packs: ["product.agentsfleet"], commands: {}, managed: [] }));
|
|
31
91
|
|
|
32
92
|
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: ENGINE_VERSION });
|
|
33
93
|
|
|
@@ -35,8 +95,8 @@ describe("opt-in pack selection", () => {
|
|
|
35
95
|
expect(result.ok).toBe(true);
|
|
36
96
|
expect(result.packs).toContain("product.agentsfleet");
|
|
37
97
|
expect(result.packs).not.toContain("workflow.governance");
|
|
38
|
-
expect(existsSync(join(repo, "docs/EXECUTE_DOC_READS.md"))).toBe(true);
|
|
39
|
-
expect(existsSync(join(repo, "dispatch/edit_rules.md"))).toBe(false);
|
|
98
|
+
expect(existsSync(join(repo, ".orly/docs/EXECUTE_DOC_READS.md"))).toBe(true);
|
|
99
|
+
expect(existsSync(join(repo, ".orly/dispatch/edit_rules.md"))).toBe(false);
|
|
40
100
|
});
|
|
41
101
|
});
|
|
42
102
|
|
|
@@ -73,7 +133,7 @@ describe("the DOC READ recorder", () => {
|
|
|
73
133
|
test("installs with the authoring pack and runs where it lands", async () => {
|
|
74
134
|
const model = await RulesModel.load(ROOT);
|
|
75
135
|
const repo = newRepository();
|
|
76
|
-
await Bun.write(join(repo, ".
|
|
136
|
+
await Bun.write(join(repo, ".orly/orly.json"), JSON.stringify({ schema_version: 1, orly_version: "", packs: [], commands: {}, managed: [] }));
|
|
77
137
|
|
|
78
138
|
const result = await install(model, { targetRoot: repo, force: false, installHooks: true, orlyVersion: ENGINE_VERSION });
|
|
79
139
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { UNSCOPED_ENVIRONMENT } from "../git_env";
|
|
4
|
+
import { OrlyError } from "../model";
|
|
5
|
+
import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "../telemetry";
|
|
6
|
+
import type { RepoConfig } from "../config";
|
|
7
|
+
import type { PlannedFile } from "./plan";
|
|
8
|
+
|
|
9
|
+
export const HOOKS_DIRECTORY = ".orly/hooks";
|
|
10
|
+
const HOOKS_PATH_KEY = "core.hooksPath";
|
|
11
|
+
const GIT_CONFIG = "config";
|
|
12
|
+
const PIPE_OUTPUT = "pipe";
|
|
13
|
+
const HOOK_GATES = [["pre-commit", "work"], ["pre-push", "verify"]] as const;
|
|
14
|
+
const EXECUTABLE_BITS = 0o111;
|
|
15
|
+
|
|
16
|
+
export function plannedHooks(version: string): PlannedFile[] {
|
|
17
|
+
if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) throw new OrlyError("hook package version must be a release version");
|
|
18
|
+
return HOOK_GATES.map(([name, gate]) => ({ target: `${HOOKS_DIRECTORY}/${name}`, content: Buffer.from(hookScript(gate, version)), mode: "0755" }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkHooks(root: string, config: RepoConfig, force: boolean): void {
|
|
22
|
+
const existing = hooksPath(root);
|
|
23
|
+
if (existing === HOOKS_DIRECTORY || force) return;
|
|
24
|
+
const previous = existing ?? git(root, ["rev-parse", "--git-path", "hooks"]).stdout.toString().trim();
|
|
25
|
+
const directory = resolve(root, previous);
|
|
26
|
+
const managed = existing === ".githooks" && HOOK_GATES.every(([name]) => config.managed.includes(`${existing}/${name}`));
|
|
27
|
+
const ownerHook = existsSync(directory) && readdirSync(directory).some((name) => {
|
|
28
|
+
if (name.endsWith(".sample") || (managed && HOOK_GATES.some(([hook]) => hook === name))) return false;
|
|
29
|
+
const stat = statSync(join(directory, name));
|
|
30
|
+
return stat.isFile() && (stat.mode & EXECUTABLE_BITS) !== 0;
|
|
31
|
+
});
|
|
32
|
+
if ((!existing || managed) && !ownerHook) return;
|
|
33
|
+
throw new OrlyError(`${HOOKS_PATH_KEY} uses '${previous}' with repository hooks; use --no-hooks to preserve repository hooks, or explicitly --force to retarget`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hooksPath(root: string): string | null {
|
|
37
|
+
const result = git(root, [GIT_CONFIG, "--get", HOOKS_PATH_KEY]);
|
|
38
|
+
if (result.exitCode === 1) return null;
|
|
39
|
+
if (result.exitCode !== 0) throw new OrlyError(`cannot read ${HOOKS_PATH_KEY}: ${result.stderr.toString().trim()}`);
|
|
40
|
+
return result.stdout.toString().trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function configureHooks(root: string): void {
|
|
44
|
+
const result = git(root, [GIT_CONFIG, HOOKS_PATH_KEY, HOOKS_DIRECTORY]);
|
|
45
|
+
if (result.exitCode !== 0) throw new OrlyError(`cannot configure ${HOOKS_PATH_KEY}: ${result.stderr.toString().trim()}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function git(root: string, args: string[]) {
|
|
49
|
+
return Bun.spawnSync(["git", "-C", root, ...args], { env: UNSCOPED_ENVIRONMENT, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hookScript(gate: string, version: string): string {
|
|
53
|
+
return [
|
|
54
|
+
"#!/usr/bin/env bash",
|
|
55
|
+
"# Generated by orly. Re-run the pinned bunx update to refresh.",
|
|
56
|
+
"set -euo pipefail",
|
|
57
|
+
"unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX \\",
|
|
58
|
+
" GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
59
|
+
`export ${HOOK_INVOCATION_ENV}=${HOOK_INVOCATION_VALUE}`,
|
|
60
|
+
`exec bunx --bun @agentsfleet/orly@${version} gate ${gate}`,
|
|
61
|
+
"",
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { cleanupTemporaryDirectories, newRepository, ROOT } from "../gates_test_support";
|
|
5
|
+
import { UNSCOPED_ENVIRONMENT } from "../git_env";
|
|
6
|
+
import { installationLock } from "./lock";
|
|
7
|
+
|
|
8
|
+
const LOCK_MODULE = join(ROOT, "src/installation/lock.ts");
|
|
9
|
+
const LOCK_PATH = ".orly/install.lock";
|
|
10
|
+
|
|
11
|
+
afterEach(cleanupTemporaryDirectories);
|
|
12
|
+
|
|
13
|
+
function leaveStaleLock(root: string): void {
|
|
14
|
+
const result = Bun.spawnSync([process.execPath, "-e", `import {installationLock} from ${JSON.stringify(LOCK_MODULE)}; installationLock(process.argv[1]);`, root], {
|
|
15
|
+
env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: 5000,
|
|
16
|
+
});
|
|
17
|
+
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("stale cleanup cannot remove a lock acquired by another installer", () => {
|
|
21
|
+
const root = newRepository();
|
|
22
|
+
leaveStaleLock(root);
|
|
23
|
+
const actor = join(root, "actor.ts");
|
|
24
|
+
writeFileSync(actor, `import * as fs from "node:fs";
|
|
25
|
+
import {join} from "node:path";
|
|
26
|
+
import {installationLock} from ${JSON.stringify(LOCK_MODULE)};
|
|
27
|
+
const root=Bun.argv[2];
|
|
28
|
+
const release=installationLock(root);
|
|
29
|
+
fs.writeFileSync(join(root,"ready"),String(process.pid));
|
|
30
|
+
const until=performance.now()+5000;
|
|
31
|
+
while(!fs.existsSync(join(root,"stop")) && performance.now()<until) await Bun.sleep(10);
|
|
32
|
+
try {release();} catch(error) {console.error(String(error));}
|
|
33
|
+
`);
|
|
34
|
+
const contender = join(root, "contender.ts");
|
|
35
|
+
writeFileSync(contender, `import * as fs from "node:fs";
|
|
36
|
+
import {join} from "node:path";
|
|
37
|
+
import {mock} from "bun:test";
|
|
38
|
+
const root=Bun.argv[2];
|
|
39
|
+
let other;
|
|
40
|
+
let attempted=false;
|
|
41
|
+
const unlink=fs.unlinkSync;
|
|
42
|
+
mock.module("node:fs",()=>({...fs,unlinkSync(path) {
|
|
43
|
+
if(!attempted) {
|
|
44
|
+
attempted=true;
|
|
45
|
+
other=Bun.spawn([process.execPath,${JSON.stringify(actor)},root],{stdout:"pipe",stderr:"pipe"});
|
|
46
|
+
const until=performance.now()+3000;
|
|
47
|
+
while(!fs.existsSync(join(root,"ready")) && performance.now()<until) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,10);
|
|
48
|
+
if(!fs.existsSync(join(root,"ready"))) throw new Error("contender did not reach acquisition");
|
|
49
|
+
}
|
|
50
|
+
unlink(path);
|
|
51
|
+
}}));
|
|
52
|
+
const {installationLock}=await import(${JSON.stringify(LOCK_MODULE)});
|
|
53
|
+
let acquired=false;
|
|
54
|
+
let release;
|
|
55
|
+
try {release=installationLock(root);acquired=true;} catch {}
|
|
56
|
+
console.log(JSON.stringify({acquired,otherAcquired:fs.existsSync(join(root,"ready")),attempted}));
|
|
57
|
+
if(release) release();
|
|
58
|
+
fs.writeFileSync(join(root,"stop"),"stop");
|
|
59
|
+
if(other) await other.exited;
|
|
60
|
+
`);
|
|
61
|
+
const result = Bun.spawnSync([process.execPath, contender, root], {
|
|
62
|
+
env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: 10_000,
|
|
63
|
+
});
|
|
64
|
+
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
|
65
|
+
const observed = JSON.parse(result.stdout.toString());
|
|
66
|
+
expect(observed.attempted).toBe(true);
|
|
67
|
+
expect(observed.otherAcquired).toBe(true);
|
|
68
|
+
expect(observed.acquired).toBe(false);
|
|
69
|
+
expect(existsSync(join(root, LOCK_PATH))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a live owner excludes contenders and a released lock can be acquired again", () => {
|
|
73
|
+
const root = newRepository();
|
|
74
|
+
const release = installationLock(root);
|
|
75
|
+
expect(() => installationLock(root)).toThrow("another installation");
|
|
76
|
+
release();
|
|
77
|
+
installationLock(root)();
|
|
78
|
+
expect(existsSync(join(root, LOCK_PATH))).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a dead owner can be recovered without changing unrelated owner files", () => {
|
|
82
|
+
const root = newRepository();
|
|
83
|
+
const owner = join(root, "owner-note");
|
|
84
|
+
writeFileSync(owner, "owner data");
|
|
85
|
+
leaveStaleLock(root);
|
|
86
|
+
installationLock(root)();
|
|
87
|
+
expect(readFileSync(owner, "utf8")).toBe("owner data");
|
|
88
|
+
expect(existsSync(join(root, LOCK_PATH))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an unrecognized lock file is preserved for owner inspection", () => {
|
|
92
|
+
const root = newRepository();
|
|
93
|
+
mkdirSync(join(root, ".orly"));
|
|
94
|
+
writeFileSync(join(root, LOCK_PATH), "owner data");
|
|
95
|
+
expect(() => installationLock(root)).toThrow("inspect it");
|
|
96
|
+
expect(readFileSync(join(root, LOCK_PATH), "utf8")).toBe("owner data");
|
|
97
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertWritableInside, OrlyError } from "../model";
|
|
5
|
+
|
|
6
|
+
const LOCK = ".orly/install.lock";
|
|
7
|
+
const ENCODING = "utf8";
|
|
8
|
+
const OWNER_NAME = /^(\d+)\.[a-f0-9-]{36}$/;
|
|
9
|
+
const INCOMPLETE = "installation lock is incomplete; inspect it before removing it";
|
|
10
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
11
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
12
|
+
|
|
13
|
+
function isCode(error: unknown, code: string): boolean {
|
|
14
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function recoverDeadOwner(root: string, path: string): void {
|
|
18
|
+
if (!lstatSync(path).isDirectory()) throw new OrlyError(INCOMPLETE);
|
|
19
|
+
const entries = readdirSync(path);
|
|
20
|
+
const owner = entries.length === 1 ? entries[0]! : "";
|
|
21
|
+
const match = OWNER_NAME.exec(owner);
|
|
22
|
+
if (!match) throw new OrlyError(INCOMPLETE);
|
|
23
|
+
const ownerPath = join(path, owner);
|
|
24
|
+
assertWritableInside(root, `${LOCK}/${owner}`, "installation lock owner");
|
|
25
|
+
const pid = Number(match[1]);
|
|
26
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !lstatSync(ownerPath).isFile() || readFileSync(ownerPath, ENCODING) !== String(pid)) throw new OrlyError(INCOMPLETE);
|
|
27
|
+
try { process.kill(pid, 0); throw new OrlyError("another installation owns this repository"); }
|
|
28
|
+
catch (error) { if (!isCode(error, "ESRCH")) throw error; }
|
|
29
|
+
// A replacement owner has a different filename. Losing this exact removal
|
|
30
|
+
// race stops recovery before it can remove the replacement directory.
|
|
31
|
+
unlinkSync(ownerPath);
|
|
32
|
+
rmdirSync(path);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function installationLock(root: string): () => void {
|
|
36
|
+
assertWritableInside(root, LOCK, "installation lock");
|
|
37
|
+
mkdirSync(join(root, ".orly"), { recursive: true });
|
|
38
|
+
const path = join(root, LOCK);
|
|
39
|
+
try { mkdirSync(path, { mode: PRIVATE_DIRECTORY_MODE }); }
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (!isCode(error, "EEXIST")) throw error;
|
|
42
|
+
recoverDeadOwner(root, path);
|
|
43
|
+
mkdirSync(path, { mode: PRIVATE_DIRECTORY_MODE });
|
|
44
|
+
}
|
|
45
|
+
const ownerPath = join(path, `${process.pid}.${randomUUID()}`);
|
|
46
|
+
try { writeFileSync(ownerPath, String(process.pid), { flag: "wx", mode: PRIVATE_FILE_MODE }); }
|
|
47
|
+
catch (error) {
|
|
48
|
+
rmdirSync(path);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
if (readFileSync(ownerPath, ENCODING) !== String(process.pid)) throw new OrlyError("installation lock ownership changed");
|
|
53
|
+
unlinkSync(ownerPath);
|
|
54
|
+
rmdirSync(path);
|
|
55
|
+
};
|
|
56
|
+
}
|