@agentsfleet/orly 0.10.13 → 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 +18 -6
- 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 +39 -24
- package/src/gates_spec.test.ts +110 -4
- 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,212 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_PATH, contentDigest, readConfig } from "../config";
|
|
4
|
+
import { cleanupTemporaryDirectories, newRepository, temporaryDirectory } from "../gates_test_support";
|
|
5
|
+
import { install } from "../install";
|
|
6
|
+
import { RulesModel } from "../model";
|
|
7
|
+
import { AGENTS_FILENAME } from "../loaders";
|
|
8
|
+
import { PREVIOUS_CONFIG } from "./migration";
|
|
9
|
+
import { HOOKS_DIRECTORY, hooksPath } from "./hooks";
|
|
10
|
+
import type { FileWrite } from "./transaction";
|
|
11
|
+
import { UNSCOPED_ENVIRONMENT } from "../git_env";
|
|
12
|
+
import { chmodSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const OLD_RULE = "dispatch/check.md";
|
|
15
|
+
const OLD_AGENTS = "AGENTS.orly.md";
|
|
16
|
+
const OWNER_RULES = "# Owner rules\nKeep this instruction.\n";
|
|
17
|
+
const OLD_BODY = "old managed content\n";
|
|
18
|
+
const OWNER_NOTE = ".oracle/owner-note";
|
|
19
|
+
const UNOWNED_BODY = "unowned state\n";
|
|
20
|
+
const CURRENT_RULE = ".orly/dispatch/check.md";
|
|
21
|
+
const CONTENT_ENCODING = "base64";
|
|
22
|
+
const VERSION = "0.12.0";
|
|
23
|
+
const JOURNAL = ".orly/install-journal.json";
|
|
24
|
+
const WRITE_COUNT = 6;
|
|
25
|
+
const HOOK_WRITE_COUNT = 8;
|
|
26
|
+
const JOURNAL_STAGE = "journal";
|
|
27
|
+
const WRITE_STAGE = "write";
|
|
28
|
+
const DELETE_STAGE = "delete";
|
|
29
|
+
const METADATA_STAGE = "metadata";
|
|
30
|
+
const INTERRUPTION = "injected interruption";
|
|
31
|
+
const OWNER_HOOKS = ".owner-hooks";
|
|
32
|
+
const OPENCODE = "opencode.json";
|
|
33
|
+
const OWNER_DOCUMENT = "owner.md";
|
|
34
|
+
const STEPS: Array<[string, number]> = [
|
|
35
|
+
[JOURNAL_STAGE, 0], ...Array.from({ length: WRITE_COUNT }, (_, index): [string, number] => [WRITE_STAGE, index]),
|
|
36
|
+
[METADATA_STAGE, 0], [DELETE_STAGE, 0], [DELETE_STAGE, 1], [DELETE_STAGE, 2],
|
|
37
|
+
];
|
|
38
|
+
const HOOK_STEPS: Array<[string, number]> = [
|
|
39
|
+
[JOURNAL_STAGE, 0], ...Array.from({ length: HOOK_WRITE_COUNT }, (_, index): [string, number] => [WRITE_STAGE, index]),
|
|
40
|
+
[METADATA_STAGE, 0], [DELETE_STAGE, 0], [DELETE_STAGE, 1], [DELETE_STAGE, 2],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
afterEach(cleanupTemporaryDirectories);
|
|
44
|
+
|
|
45
|
+
test.each([".git/hooks", ".githooks"])("an active owner hook in %s remains connected", async (directory) => {
|
|
46
|
+
const { repo, model, options } = await fixture();
|
|
47
|
+
const path = join(repo, directory, "commit-msg");
|
|
48
|
+
const owner = "#!/bin/sh\nexit 0\n";
|
|
49
|
+
await Bun.write(path, owner);
|
|
50
|
+
chmodSync(path, 0o755);
|
|
51
|
+
if (directory === ".githooks") {
|
|
52
|
+
const config = await Bun.file(join(repo, PREVIOUS_CONFIG)).json();
|
|
53
|
+
config.managed.push(".githooks/pre-commit", ".githooks/pre-push");
|
|
54
|
+
await Bun.write(join(repo, PREVIOUS_CONFIG), JSON.stringify(config));
|
|
55
|
+
expect(Bun.spawnSync(["git", "-C", repo, "config", "core.hooksPath", directory]).exitCode).toBe(0);
|
|
56
|
+
}
|
|
57
|
+
const before = hooksPath(repo);
|
|
58
|
+
const result = await install(model, { ...options, installHooks: true });
|
|
59
|
+
expect(result.ok).toBe(false);
|
|
60
|
+
expect(result.errors.map((error) => error.message).join("\n")).toContain("--no-hooks");
|
|
61
|
+
expect(hooksPath(repo)).toBe(before);
|
|
62
|
+
expect(await Bun.file(path).text()).toBe(owner);
|
|
63
|
+
expect((await install(model, options)).ok).toBe(true);
|
|
64
|
+
expect(hooksPath(repo)).toBe(before);
|
|
65
|
+
expect(await Bun.file(path).text()).toBe(owner);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function fixture() {
|
|
69
|
+
const repo = newRepository();
|
|
70
|
+
const engine = temporaryDirectory();
|
|
71
|
+
await Bun.write(join(engine, "core.md"), "new operating model\n");
|
|
72
|
+
await Bun.write(join(engine, "guide.md"), "new managed guide\n");
|
|
73
|
+
const model = new RulesModel(engine, { core_documents: ["core.md"], packs: { base: { extensions: [], managed_files: [{ source: "guide.md", target: OLD_RULE }] } }, rules: [] });
|
|
74
|
+
await Bun.write(join(repo, OLD_RULE), OLD_BODY);
|
|
75
|
+
await Bun.write(join(repo, OLD_AGENTS), OLD_BODY);
|
|
76
|
+
await Bun.write(join(repo, AGENTS_FILENAME), OWNER_RULES);
|
|
77
|
+
await Bun.write(join(repo, PREVIOUS_CONFIG), JSON.stringify({
|
|
78
|
+
schema_version: 1, orly_version: "0.11.0", packs: [], commands: { conform: [["true"]], "verify.unit": [["true"]] },
|
|
79
|
+
managed: [OLD_RULE, OLD_AGENTS], digests: { [OLD_RULE]: contentDigest(Buffer.from(OLD_BODY)), [OLD_AGENTS]: contentDigest(Buffer.from(OLD_BODY)) },
|
|
80
|
+
}));
|
|
81
|
+
await Bun.write(join(repo, OWNER_NOTE), UNOWNED_BODY);
|
|
82
|
+
return { repo, model, options: { targetRoot: repo, force: false, installHooks: false, orlyVersion: VERSION } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test.each(STEPS)("interrupted_install_recovers at %s %d", async (stage, ordinal) => {
|
|
86
|
+
await recoverInterruptedFixture(stage, ordinal, false, WRITE_COUNT);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test.each(HOOK_STEPS)("hook installation recovers interruption at %s %d", async (stage, ordinal) => {
|
|
90
|
+
await recoverInterruptedFixture(stage, ordinal, true, HOOK_WRITE_COUNT);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
async function recoverInterruptedFixture(stage: string, ordinal: number, installHooks: boolean, writeCount: number) {
|
|
94
|
+
const { repo, model, options } = await fixture();
|
|
95
|
+
options.installHooks = installHooks;
|
|
96
|
+
const previous = await Bun.file(join(repo, PREVIOUS_CONFIG)).text();
|
|
97
|
+
let injected = 0;
|
|
98
|
+
const result = await install(model, { ...options, checkpoint: (current, index) => {
|
|
99
|
+
if (current === stage && index === ordinal) { injected++; throw new Error(INTERRUPTION); }
|
|
100
|
+
} });
|
|
101
|
+
expect(injected).toBe(1);
|
|
102
|
+
expect(result.ok).toBe(false);
|
|
103
|
+
expect(result.errors.map((error) => error.message)).toEqual([INTERRUPTION]);
|
|
104
|
+
await expectInterruptedState(repo, stage, ordinal, previous, writeCount);
|
|
105
|
+
const configured = installHooks && (stage === METADATA_STAGE || stage === DELETE_STAGE);
|
|
106
|
+
expect(hooksPath(repo)).toBe(configured ? HOOKS_DIRECTORY : null);
|
|
107
|
+
expect((await install(model, options)).errors).toEqual([]);
|
|
108
|
+
expect(await Bun.file(join(repo, CURRENT_RULE)).text()).toBe("new managed guide\n");
|
|
109
|
+
expect(await Bun.file(join(repo, AGENTS_FILENAME)).text()).toStartWith(OWNER_RULES.trim());
|
|
110
|
+
expect(await Bun.file(join(repo, OWNER_NOTE)).text()).toBe(UNOWNED_BODY);
|
|
111
|
+
for (const path of [OLD_RULE, OLD_AGENTS, PREVIOUS_CONFIG, JOURNAL]) expect(await Bun.file(join(repo, path)).exists()).toBe(false);
|
|
112
|
+
expect((await readConfig(repo))?.orly_version).toBe(VERSION);
|
|
113
|
+
expect(hooksPath(repo)).toBe(installHooks ? HOOKS_DIRECTORY : null);
|
|
114
|
+
expect((await install(model, options)).written).toEqual([]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function expectInterruptedState(repo: string, stage: string, ordinal: number, previous: string, writeCount: number): Promise<void> {
|
|
118
|
+
const journal: { data: { writes: FileWrite[] } } = await Bun.file(join(repo, JOURNAL)).json();
|
|
119
|
+
expect(journal.data.writes).toHaveLength(writeCount);
|
|
120
|
+
const written = stage === JOURNAL_STAGE ? 0 : stage === WRITE_STAGE ? ordinal + 1 : writeCount;
|
|
121
|
+
for (const [index, file] of journal.data.writes.entries()) {
|
|
122
|
+
const current = Bun.file(join(repo, file.path));
|
|
123
|
+
if (index < written) expect(await current.bytes()).toEqual(Buffer.from(file.content, CONTENT_ENCODING));
|
|
124
|
+
else if (file.before === null) expect(await current.exists()).toBe(false);
|
|
125
|
+
else expect(contentDigest(await current.bytes())).toBe(file.before);
|
|
126
|
+
}
|
|
127
|
+
expect((await readConfig(repo))?.orly_version).toBe(written === writeCount ? VERSION : undefined);
|
|
128
|
+
const obsolete: Array<[string, string]> = [[OLD_RULE, OLD_BODY], [OLD_AGENTS, OLD_BODY], [PREVIOUS_CONFIG, previous]];
|
|
129
|
+
for (const [index, [path, bytes]] of obsolete.entries()) {
|
|
130
|
+
const deleted = stage === DELETE_STAGE && index <= ordinal;
|
|
131
|
+
expect(await Bun.file(join(repo, path)).exists()).toBe(!deleted);
|
|
132
|
+
if (!deleted) expect(await Bun.file(join(repo, path)).text()).toBe(bytes);
|
|
133
|
+
}
|
|
134
|
+
expect(await Bun.file(join(repo, OWNER_NOTE)).text()).toBe(UNOWNED_BODY);
|
|
135
|
+
expect(await Bun.file(join(repo, AGENTS_FILENAME)).text()).toStartWith(OWNER_RULES.trim());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
test.each([OLD_RULE, OLD_AGENTS, CURRENT_RULE])("migration_preserves_owned_content refuses edited or unowned %s", async (path) => {
|
|
139
|
+
const { repo, model, options } = await fixture();
|
|
140
|
+
const correction = "owner correction\n";
|
|
141
|
+
await Bun.write(join(repo, path), correction);
|
|
142
|
+
const before = await Bun.file(join(repo, PREVIOUS_CONFIG)).text();
|
|
143
|
+
expect((await install(model, options)).ok).toBe(false);
|
|
144
|
+
expect(await Bun.file(join(repo, path)).text()).toBe(correction);
|
|
145
|
+
expect(await Bun.file(join(repo, PREVIOUS_CONFIG)).text()).toBe(before);
|
|
146
|
+
expect(await Bun.file(join(repo, ".orly/orly.json")).exists()).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("recovery refuses an edited destination before deleting any old source", async () => {
|
|
150
|
+
const { repo, model, options } = await fixture();
|
|
151
|
+
await install(model, { ...options, checkpoint: (stage) => { if (stage === "metadata") throw new Error("interruption"); } });
|
|
152
|
+
await Bun.write(join(repo, CURRENT_RULE), "concurrent owner correction\n");
|
|
153
|
+
await expect(install(model, options)).rejects.toThrow("changed after planning");
|
|
154
|
+
expect(await Bun.file(join(repo, OLD_RULE)).text()).toBe(OLD_BODY);
|
|
155
|
+
expect(await Bun.file(join(repo, PREVIOUS_CONFIG)).exists()).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("changed journal cannot authorize recovery", async () => {
|
|
159
|
+
const { repo, model, options } = await fixture();
|
|
160
|
+
await install(model, { ...options, checkpoint: () => { throw new Error("interruption"); } });
|
|
161
|
+
const journal = await Bun.file(join(repo, JOURNAL)).json();
|
|
162
|
+
journal.data.deletes = [];
|
|
163
|
+
await Bun.write(join(repo, JOURNAL), JSON.stringify(journal));
|
|
164
|
+
await expect(install(model, options)).rejects.toThrow("integrity mismatch");
|
|
165
|
+
expect(await Bun.file(join(repo, OLD_RULE)).text()).toBe(OLD_BODY);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a current installation preserves an independent previous configuration", async () => {
|
|
169
|
+
const { repo, model, options } = await fixture();
|
|
170
|
+
const independent = await Bun.file(join(repo, PREVIOUS_CONFIG)).text();
|
|
171
|
+
await Bun.write(join(repo, CONFIG_PATH), JSON.stringify({ schema_version: 1, packs: [], managed: [], digests: {}, commands: {} }));
|
|
172
|
+
expect((await install(model, options)).ok).toBe(true);
|
|
173
|
+
expect(await Bun.file(join(repo, PREVIOUS_CONFIG)).text()).toBe(independent);
|
|
174
|
+
expect(await Bun.file(join(repo, OLD_RULE)).text()).toBe(OLD_BODY);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test.each([true, false])("recovery preserves an owner-changed hooks setting with resumed hooks %s", async (installHooks) => {
|
|
178
|
+
const { repo, model, options } = await fixture();
|
|
179
|
+
const interrupted = await install(model, { ...options, installHooks: true, checkpoint: (stage) => {
|
|
180
|
+
if (stage === "journal") throw new Error("interrupt before hook configuration");
|
|
181
|
+
} });
|
|
182
|
+
expect(interrupted.ok).toBe(false);
|
|
183
|
+
const changed = Bun.spawnSync(["git", "config", "core.hooksPath", OWNER_HOOKS], { cwd: repo, env: UNSCOPED_ENVIRONMENT });
|
|
184
|
+
expect(changed.exitCode).toBe(0);
|
|
185
|
+
await expect(install(model, { ...options, installHooks })).rejects.toThrow("hooks setting changed");
|
|
186
|
+
const current = Bun.spawnSync(["git", "config", "--get", "core.hooksPath"], { cwd: repo, env: UNSCOPED_ENVIRONMENT, stdout: "pipe" });
|
|
187
|
+
expect(current.stdout.toString().trim()).toBe(OWNER_HOOKS);
|
|
188
|
+
expect(await Bun.file(join(repo, OLD_RULE)).text()).toBe(OLD_BODY);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("owner hook changes during destination writes are refused before metadata or cleanup", async () => {
|
|
192
|
+
const { repo, model, options } = await fixture();
|
|
193
|
+
const result = await install(model, { ...options, installHooks: true, checkpoint: (stage, index) => {
|
|
194
|
+
if (stage === WRITE_STAGE && index === HOOK_WRITE_COUNT - 1) {
|
|
195
|
+
const changed = Bun.spawnSync(["git", "config", "core.hooksPath", OWNER_HOOKS], { cwd: repo, env: UNSCOPED_ENVIRONMENT });
|
|
196
|
+
expect(changed.exitCode).toBe(0);
|
|
197
|
+
}
|
|
198
|
+
} });
|
|
199
|
+
expect(result.ok).toBeFalse();
|
|
200
|
+
expect(result.errors.map((error) => error.message)).toEqual(["installation conflict: hooks setting changed after planning"]);
|
|
201
|
+
expect(hooksPath(repo)).toBe(OWNER_HOOKS);
|
|
202
|
+
expect(await Bun.file(join(repo, OLD_RULE)).text()).toBe(OLD_BODY);
|
|
203
|
+
expect(await Bun.file(join(repo, PREVIOUS_CONFIG)).exists()).toBeTrue();
|
|
204
|
+
expect(await Bun.file(join(repo, JOURNAL)).exists()).toBeTrue();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("migration removes only proven obsolete instruction references", async () => {
|
|
208
|
+
const { repo, model, options } = await fixture();
|
|
209
|
+
await Bun.write(join(repo, OPENCODE), JSON.stringify({ instructions: [OLD_AGENTS, OWNER_DOCUMENT] }));
|
|
210
|
+
expect((await install(model, options)).ok).toBe(true);
|
|
211
|
+
expect((await Bun.file(join(repo, OPENCODE)).json()).instructions).toEqual([OWNER_DOCUMENT, "AGENTS.md", ".orly/AGENTS.md"]);
|
|
212
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_PATH, contentDigest, parseConfig, readConfig, sameDigest, type RepoConfig } from "../config";
|
|
4
|
+
import { assertWritableInside, OrlyError } from "../model";
|
|
5
|
+
import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "../telemetry";
|
|
6
|
+
import type { FileDelete } from "./transaction";
|
|
7
|
+
|
|
8
|
+
export const PREVIOUS_CONFIG = ".oracle/orly.json";
|
|
9
|
+
const TEXT_ENCODING = "utf8";
|
|
10
|
+
|
|
11
|
+
// Only installation may read the old location. Gates require the current one.
|
|
12
|
+
export async function readInstallConfig(root: string): Promise<RepoConfig | undefined> {
|
|
13
|
+
const current = await readConfig(root);
|
|
14
|
+
if (current) return current;
|
|
15
|
+
assertWritableInside(root, PREVIOUS_CONFIG, "migration configuration");
|
|
16
|
+
const path = join(root, PREVIOUS_CONFIG);
|
|
17
|
+
if (!existsSync(path)) return undefined;
|
|
18
|
+
return parseConfig(JSON.parse(readFileSync(path, TEXT_ENCODING)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function migrationDeletes(root: string, config: RepoConfig, destinations: Set<string>): FileDelete[] {
|
|
22
|
+
const deletes: FileDelete[] = [];
|
|
23
|
+
for (const path of config.managed) {
|
|
24
|
+
if (destinations.has(path) || path === "AGENTS.md") continue;
|
|
25
|
+
assertWritableInside(root, path, "migration source");
|
|
26
|
+
if (!existsSync(join(root, path))) continue;
|
|
27
|
+
const current = contentDigest(readFileSync(join(root, path)));
|
|
28
|
+
const recorded = config.digests[path] ?? previousHookDigest(root, path);
|
|
29
|
+
if (!recorded || !sameDigest(recorded, current)) throw new OrlyError(`migration conflict: ${path} lacks matching recorded ownership; preserve or reconcile it before updating`);
|
|
30
|
+
deletes.push({ path, before: current });
|
|
31
|
+
}
|
|
32
|
+
if (!existsSync(join(root, CONFIG_PATH)) && existsSync(join(root, PREVIOUS_CONFIG))) {
|
|
33
|
+
deletes.push({ path: PREVIOUS_CONFIG, before: contentDigest(readFileSync(join(root, PREVIOUS_CONFIG))) });
|
|
34
|
+
}
|
|
35
|
+
return deletes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function previousHookDigest(root: string, path: string): string | undefined {
|
|
39
|
+
const gate = path === ".githooks/pre-commit" ? "work" : path === ".githooks/pre-push" ? "verify" : undefined;
|
|
40
|
+
if (!gate) return undefined;
|
|
41
|
+
const content = readFileSync(join(root, path));
|
|
42
|
+
return content.toString(TEXT_ENCODING) === previousHookScript(gate) ? contentDigest(content) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function previousHookScript(gate: string): string {
|
|
46
|
+
return [
|
|
47
|
+
"#!/usr/bin/env bash",
|
|
48
|
+
"# Generated by orly. Re-run `orly init` to refresh.",
|
|
49
|
+
"set -euo pipefail",
|
|
50
|
+
"",
|
|
51
|
+
"# git exports these to every hook; inherited by a spawned git they pin it to",
|
|
52
|
+
"# the hook's repository instead of the one being judged.",
|
|
53
|
+
"unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX \\",
|
|
54
|
+
" GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
55
|
+
"",
|
|
56
|
+
'if ! command -v orly >/dev/null 2>&1; then',
|
|
57
|
+
" printf '%s\\n' 'orly: not on PATH — install it with `bun add -g @agentsfleet/orly`' >&2",
|
|
58
|
+
" exit 1",
|
|
59
|
+
"fi",
|
|
60
|
+
"",
|
|
61
|
+
`export ${HOOK_INVOCATION_ENV}=${HOOK_INVOCATION_VALUE}`,
|
|
62
|
+
`exec orly gate ${gate}`,
|
|
63
|
+
"",
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { extname, join, resolve } from "node:path";
|
|
3
|
+
import { isString, modeLabel, objectArray, objectValue, OrlyError, type RulesModel } from "../model";
|
|
4
|
+
import { AGENTS_FILENAME, ORLY_AGENTS_FILENAME, type Layout } from "../loaders";
|
|
5
|
+
import { renderProfileText } from "../references";
|
|
6
|
+
import { Renderer } from "../render";
|
|
7
|
+
|
|
8
|
+
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
9
|
+
const MARKDOWN_EXTENSION = ".md";
|
|
10
|
+
const SKILLS_SEGMENT = "/skills/";
|
|
11
|
+
const MODE_REGULAR = "0644";
|
|
12
|
+
export type PlannedFile = { target: string; content: Uint8Array; mode: string };
|
|
13
|
+
|
|
14
|
+
export function installedPath(target: string): string {
|
|
15
|
+
if (target.startsWith(".orly/")) return target;
|
|
16
|
+
if (target.includes(SKILLS_SEGMENT)) return `.orly/skills/${target.split(SKILLS_SEGMENT)[1]}`;
|
|
17
|
+
return `.orly/${target}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function installedReferences(text: string, paths: Set<string>): string {
|
|
21
|
+
return text.replaceAll("`../.orly/AGENTS.md`", "`../AGENTS.md`").replaceAll("](../.orly/AGENTS.md)", "](../AGENTS.md)")
|
|
22
|
+
.replaceAll("](.orly/AGENTS.md)", "](AGENTS.md)")
|
|
23
|
+
.replace(/(?<![\w./-])(dispatch|audits)\//g, ".orly/$1/")
|
|
24
|
+
.replace(/(?<![\w./-])docs\/(?:greptile-learnings\/[^\s`)'"]+|[A-Z][A-Z_0-9.]+\.md)/g, (path) => paths.has(path) ? `.orly/${path}` : path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One entry per target, so two packs naming the same file collapse instead of
|
|
28
|
+
// racing. Two packs naming DIFFERENT sources for one target is a registry bug
|
|
29
|
+
// and stops the install rather than letting pack order decide the winner.
|
|
30
|
+
export async function planFiles(model: RulesModel, packs: string[], commands: Record<string, string[][]>, targetRoot: string, orlyFile: string): Promise<PlannedFile[]> {
|
|
31
|
+
const registryPacks = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
|
|
32
|
+
const known = new Set(Object.keys(registryPacks));
|
|
33
|
+
const paths = new Set(Object.values(registryPacks).flatMap((pack) => objectArray(objectValue(pack, "pack").managed_files, "managed files").map((entry) => String(entry.target))));
|
|
34
|
+
const planned = new Map<string, PlannedFile>();
|
|
35
|
+
const sources = new Map<string, string>();
|
|
36
|
+
for (const name of packs) {
|
|
37
|
+
const pack = objectValue(registryPacks[name], `pack ${name}`);
|
|
38
|
+
for (const entry of objectArray(pack.managed_files, `pack ${name} managed_files`)) {
|
|
39
|
+
if (!isString(entry.source) || !isString(entry.target)) throw new OrlyError(`pack ${name} managed file must carry string source and target`);
|
|
40
|
+
const claimed = sources.get(entry.target);
|
|
41
|
+
if (claimed && claimed !== entry.source) throw new OrlyError(`packs disagree on ${entry.target}: ${claimed} and ${entry.source}`);
|
|
42
|
+
sources.set(entry.target, entry.source);
|
|
43
|
+
const path = join(model.root, entry.source);
|
|
44
|
+
// Never overwrite the engine's pack sources with filtered consumer copies.
|
|
45
|
+
if (resolved(model.root) === resolved(targetRoot)) continue;
|
|
46
|
+
const target = installedPath(entry.target);
|
|
47
|
+
planned.set(target, { target, content: await managedContent(path, target, entry.source, packs, known, orlyFile, paths), mode: modeLabel(path) });
|
|
48
|
+
if (entry.target.includes(SKILLS_SEGMENT)) {
|
|
49
|
+
const body = await Bun.file(path).text();
|
|
50
|
+
const frontmatter = body.match(/^---\n[\s\S]*?\n---/u)?.[0] ?? "";
|
|
51
|
+
const stub = `${frontmatter}\n\nRead and follow \`${target}\` from the repository root before acting.\n`;
|
|
52
|
+
planned.set(entry.target, { target: entry.target, content: new TextEncoder().encode(stub), mode: MODE_REGULAR });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const rendered = await new Renderer(model).renderText(packs, commands);
|
|
57
|
+
planned.set(orlyFile, { target: orlyFile, content: new TextEncoder().encode(orlyFile === AGENTS_FILENAME ? rendered : installedReferences(rendered, paths)), mode: MODE_REGULAR });
|
|
58
|
+
return [...planned.values()].sort((left, right) => left.target.localeCompare(right.target));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveLayout(model: RulesModel, targetRoot: string): Layout {
|
|
62
|
+
// The engine owns its render; consumers retain their own AGENTS.md.
|
|
63
|
+
if (resolved(model.root) === resolved(targetRoot)) return { orlyFile: AGENTS_FILENAME };
|
|
64
|
+
return { orlyFile: ORLY_AGENTS_FILENAME, pointerHost: AGENTS_FILENAME };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolved(path: string): string {
|
|
68
|
+
try {
|
|
69
|
+
return realpathSync(path);
|
|
70
|
+
} catch {
|
|
71
|
+
return resolve(path);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Installation and verification use the same filtered bytes for each pack.
|
|
76
|
+
export async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string, paths: Set<string> = new Set()): Promise<Uint8Array> {
|
|
77
|
+
const bytes = await Bun.file(path).bytes();
|
|
78
|
+
if (extname(target) !== MARKDOWN_EXTENSION) return bytes;
|
|
79
|
+
const filtered = renderProfileText(new TextDecoder().decode(bytes), new Set(packs), known, source);
|
|
80
|
+
const retargeted = retargetRulesCitations(filtered, orlyFile);
|
|
81
|
+
return new TextEncoder().encode(`${orlyFile === AGENTS_FILENAME ? retargeted : installedReferences(retargeted, paths)}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Managed rule citations must point to Orly's file, not the consumer's own rules.
|
|
85
|
+
function retargetRulesCitations(text: string, orlyFile: string): string {
|
|
86
|
+
if (orlyFile === AGENTS_FILENAME) return text;
|
|
87
|
+
return text
|
|
88
|
+
.replaceAll(`](${AGENTS_FILENAME})`, `](${orlyFile})`)
|
|
89
|
+
.replaceAll(`](../${AGENTS_FILENAME})`, `](../${orlyFile})`)
|
|
90
|
+
// The relative spelling first: `../AGENTS.md` contains `AGENTS.md`, so the
|
|
91
|
+
// bare rule below would rewrite its tail and leave the `../` stranded.
|
|
92
|
+
// Missing it is what rendered `[`../AGENTS.md`](../AGENTS.orly.md)` into
|
|
93
|
+
// four managed pages — a label naming one file over a link to another.
|
|
94
|
+
.replaceAll(`\`../${AGENTS_FILENAME}\``, `\`../${orlyFile}\``)
|
|
95
|
+
.replaceAll(`\`${AGENTS_FILENAME}\``, `\`${orlyFile}\``);
|
|
96
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, lstatSync } from "node:fs";
|
|
2
|
+
import { dirname, join, isAbsolute } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assertWritableInside, applyMode, OrlyError, MODE_REGULAR, MODE_EXECUTABLE } from "../model";
|
|
5
|
+
import { contentDigest } from "../config";
|
|
6
|
+
import { hooksPath } from "./hooks";
|
|
7
|
+
|
|
8
|
+
const JOURNAL = ".orly/install-journal.json";
|
|
9
|
+
const MAX_JOURNAL_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const JSON_INDENT = 2;
|
|
11
|
+
const ENCODING = "utf8";
|
|
12
|
+
const CONTENT_ENCODING = "base64";
|
|
13
|
+
const OBSOLETE_FILE = "obsolete managed file";
|
|
14
|
+
const JOURNAL_SIZE_ERROR = "installation journal exceeds its size budget";
|
|
15
|
+
const regularPath = z.string().min(1).refine((path) => !isAbsolute(path) && !path.split(/[\\/]/).some((part) => part === ".." || !part) && !path.includes("\0"));
|
|
16
|
+
const writeSchema = z.object({ path: regularPath, before: z.string().nullable(), content: z.string(), mode: z.enum([MODE_REGULAR, MODE_EXECUTABLE]) }).strict();
|
|
17
|
+
const deleteSchema = z.object({ path: regularPath, before: z.string() }).strict();
|
|
18
|
+
const hooksSchema = z.object({ before: z.string().nullable(), after: z.string() }).strict().nullable();
|
|
19
|
+
const journalSchema = z.object({ version: z.string(), writes: z.array(writeSchema), deletes: z.array(deleteSchema), hooks: hooksSchema }).strict();
|
|
20
|
+
export type FileWrite = z.infer<typeof writeSchema>;
|
|
21
|
+
export type FileDelete = z.infer<typeof deleteSchema>;
|
|
22
|
+
export type HooksChange = z.infer<typeof hooksSchema>;
|
|
23
|
+
type Journal = z.infer<typeof journalSchema>;
|
|
24
|
+
export type InstallCheckpoint = (stage: string, ordinal: number) => void;
|
|
25
|
+
|
|
26
|
+
// A crash may leave either the old bytes or the intended new bytes at a path.
|
|
27
|
+
// Anything else is an owner edit and must stop recovery before further writes.
|
|
28
|
+
export class InstallTransaction {
|
|
29
|
+
readonly #root: string;
|
|
30
|
+
readonly #version: string;
|
|
31
|
+
readonly #checkpoint: InstallCheckpoint | undefined;
|
|
32
|
+
constructor(root: string, version: string, checkpoint?: InstallCheckpoint) {
|
|
33
|
+
this.#root = root;
|
|
34
|
+
this.#version = version;
|
|
35
|
+
this.#checkpoint = checkpoint;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
recover(configureHooks: () => void): boolean {
|
|
39
|
+
assertWritableInside(this.#root, JOURNAL, "installation journal");
|
|
40
|
+
const path = join(this.#root, JOURNAL);
|
|
41
|
+
if (!existsSync(path)) return false;
|
|
42
|
+
if (lstatSync(path).size > MAX_JOURNAL_BYTES) throw new OrlyError(JOURNAL_SIZE_ERROR);
|
|
43
|
+
const envelope = z.object({ digest: z.string(), data: journalSchema }).strict().parse(JSON.parse(readFileSync(path, ENCODING)));
|
|
44
|
+
if (envelope.digest !== contentDigest(Buffer.from(JSON.stringify(envelope.data)))) throw new OrlyError("installation journal integrity mismatch");
|
|
45
|
+
if (envelope.data.version !== this.#version) throw new OrlyError("resume installation with the same package version recorded in the journal");
|
|
46
|
+
this.apply(envelope.data, configureHooks);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
commit(writes: FileWrite[], deletes: FileDelete[], hooks: HooksChange, configureHooks: () => void): void {
|
|
51
|
+
const data = journalSchema.parse({ version: this.#version, writes, deletes, hooks });
|
|
52
|
+
this.validate(data);
|
|
53
|
+
const bytes = Buffer.from(JSON.stringify({ digest: contentDigest(Buffer.from(JSON.stringify(data))), data }, undefined, JSON_INDENT));
|
|
54
|
+
if (bytes.length > MAX_JOURNAL_BYTES) throw new OrlyError(JOURNAL_SIZE_ERROR);
|
|
55
|
+
atomicWrite(this.#root, JOURNAL, bytes, MODE_REGULAR);
|
|
56
|
+
this.#checkpoint?.("journal", 0);
|
|
57
|
+
this.apply(data, configureHooks);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private validate(data: Journal): void {
|
|
61
|
+
this.validateHooks(data.hooks);
|
|
62
|
+
const paths = [...data.writes.map((file) => file.path), ...data.deletes.map((file) => file.path)];
|
|
63
|
+
if (new Set(paths).size !== paths.length || paths.includes(JOURNAL)) throw new OrlyError("installation journal contains overlapping operations");
|
|
64
|
+
for (const file of data.writes) {
|
|
65
|
+
assertWritableInside(this.#root, file.path, "installation destination");
|
|
66
|
+
const current = diskDigest(this.#root, file.path);
|
|
67
|
+
const next = contentDigest(Buffer.from(file.content, CONTENT_ENCODING));
|
|
68
|
+
if (current !== file.before && current !== next) throw new OrlyError(`installation conflict: ${file.path} changed after planning`);
|
|
69
|
+
}
|
|
70
|
+
for (const file of data.deletes) {
|
|
71
|
+
assertWritableInside(this.#root, file.path, OBSOLETE_FILE);
|
|
72
|
+
const current = diskDigest(this.#root, file.path);
|
|
73
|
+
if (current !== null && current !== file.before) throw new OrlyError(`migration conflict: ${file.path} changed before cleanup`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private validateHooks(hooks: HooksChange): void {
|
|
78
|
+
if (!hooks) return;
|
|
79
|
+
const current = hooksPath(this.#root);
|
|
80
|
+
if (current !== hooks.before && current !== hooks.after) throw new OrlyError("installation conflict: hooks setting changed after planning");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private apply(data: Journal, configureHooks: () => void): void {
|
|
84
|
+
this.validate(data);
|
|
85
|
+
for (const [index, file] of data.writes.entries()) {
|
|
86
|
+
const bytes = Buffer.from(file.content, CONTENT_ENCODING);
|
|
87
|
+
const current = diskDigest(this.#root, file.path);
|
|
88
|
+
if (current !== file.before && current !== contentDigest(bytes)) throw new OrlyError(`installation conflict: ${file.path}`);
|
|
89
|
+
if (current !== contentDigest(bytes)) atomicWrite(this.#root, file.path, bytes, file.mode);
|
|
90
|
+
else applyMode(join(this.#root, file.path), file.mode);
|
|
91
|
+
this.#checkpoint?.("write", index);
|
|
92
|
+
}
|
|
93
|
+
this.validateHooks(data.hooks);
|
|
94
|
+
if (data.hooks) configureHooks();
|
|
95
|
+
this.#checkpoint?.("metadata", 0);
|
|
96
|
+
for (const [index, file] of data.deletes.entries()) {
|
|
97
|
+
assertWritableInside(this.#root, file.path, OBSOLETE_FILE);
|
|
98
|
+
const current = diskDigest(this.#root, file.path);
|
|
99
|
+
if (current !== null && current !== file.before) throw new OrlyError(`migration conflict: ${file.path}`);
|
|
100
|
+
if (current !== null) unlinkSync(join(this.#root, file.path));
|
|
101
|
+
this.#checkpoint?.("delete", index);
|
|
102
|
+
}
|
|
103
|
+
unlinkSync(join(this.#root, JOURNAL));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function diskDigest(root: string, path: string): string | null {
|
|
108
|
+
assertWritableInside(root, path, "installation input");
|
|
109
|
+
const file = join(root, path);
|
|
110
|
+
if (!existsSync(file)) return null;
|
|
111
|
+
if (!lstatSync(file).isFile()) throw new OrlyError(`installation requires a regular file: ${path}`);
|
|
112
|
+
return contentDigest(readFileSync(file));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function atomicWrite(root: string, path: string, content: Uint8Array, mode: string): void {
|
|
116
|
+
assertWritableInside(root, path, "installation write");
|
|
117
|
+
const destination = join(root, path);
|
|
118
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
119
|
+
const temporary = `${destination}.orly-pending`;
|
|
120
|
+
assertWritableInside(root, `${path}.orly-pending`, "installation temporary");
|
|
121
|
+
if (existsSync(temporary)) {
|
|
122
|
+
if (diskDigest(root, `${path}.orly-pending`) !== contentDigest(content)) throw new OrlyError(`installation temporary conflicts: ${path}.orly-pending`);
|
|
123
|
+
} else writeFileSync(temporary, content, { flag: "wx" });
|
|
124
|
+
applyMode(temporary, mode);
|
|
125
|
+
renameSync(temporary, destination);
|
|
126
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support";
|
|
4
|
+
import { install } from "./install";
|
|
5
|
+
import { readConfig } from "./config";
|
|
6
|
+
import { RulesModel } from "./model";
|
|
7
|
+
|
|
8
|
+
afterEach(() => { mock.restore(); cleanupTemporaryDirectories(); });
|
|
9
|
+
|
|
10
|
+
test("strong_assertion_exposes_real_defect", async () => {
|
|
11
|
+
const model = await RulesModel.load(ROOT);
|
|
12
|
+
const repo = newRepository();
|
|
13
|
+
const options = { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.12.0" };
|
|
14
|
+
expect((await install(model, options)).ok).toBe(true);
|
|
15
|
+
const config = await readConfig(repo);
|
|
16
|
+
const target = config!.managed.find((path) => path.endsWith("dispatch/write_any.md"))!;
|
|
17
|
+
const edited = "Owner correction that must survive an ordinary update.\n";
|
|
18
|
+
await Bun.write(join(repo, target), edited);
|
|
19
|
+
const result = await install(model, options);
|
|
20
|
+
// Existence alone passes even when the updater destroys the correction.
|
|
21
|
+
expect(await Bun.file(join(repo, target)).exists()).toBe(true);
|
|
22
|
+
expect(await Bun.file(join(repo, target)).text()).toBe(edited);
|
|
23
|
+
expect(result.ok).toBe(false);
|
|
24
|
+
expect(result.errors.some((error) => error.path === target)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test.each([".orly/orly.json", ".orly/AGENTS.md", "AGENTS.md"])("an owner edit to %s during staging refuses without overwriting it", async (target) => {
|
|
28
|
+
const model = await RulesModel.load(ROOT);
|
|
29
|
+
const repo = newRepository();
|
|
30
|
+
const options = { targetRoot: repo, force: false, installHooks: false, orlyVersion: "0.11.0" };
|
|
31
|
+
expect((await install(model, options)).ok).toBe(true);
|
|
32
|
+
const write = Bun.write.bind(Bun);
|
|
33
|
+
const edited = "Owner edit saved while installation is staging.\n";
|
|
34
|
+
let injected = 0;
|
|
35
|
+
spyOn(Bun, "write").mockImplementation(async (destination, input, settings) => {
|
|
36
|
+
if (!injected && String(destination).includes("/install-stage-")) {
|
|
37
|
+
injected++;
|
|
38
|
+
await write(join(repo, target), edited);
|
|
39
|
+
}
|
|
40
|
+
return Reflect.apply(write, Bun, [destination, input, settings]);
|
|
41
|
+
});
|
|
42
|
+
const result = await install(model, options);
|
|
43
|
+
expect(injected).toBe(1);
|
|
44
|
+
expect(result.ok).toBe(false);
|
|
45
|
+
expect(result.errors.map((error) => error.message).join("\n")).toContain("changed after planning");
|
|
46
|
+
expect(await Bun.file(join(repo, target)).text()).toBe(edited);
|
|
47
|
+
expect(await Bun.file(join(repo, ".orly/install-journal.json")).exists()).toBe(false);
|
|
48
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { CHOICE } from "./constants";
|
|
4
|
+
import { assess } from "./advice";
|
|
5
|
+
import { CATALOG } from "./questions";
|
|
6
|
+
import { choiceReply, noulReply, TestProject } from "./test_support";
|
|
7
|
+
|
|
8
|
+
test("weak assertions receive a fixed exact-result action", async () => {
|
|
9
|
+
using project = new TestProject();
|
|
10
|
+
const result = assess(await project.prepare(), choiceReply());
|
|
11
|
+
expect(result.concern).toBe(true);
|
|
12
|
+
expect(result.uncertain).toBe(false);
|
|
13
|
+
expect(result.action).toContain("exact expected result");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("insufficient and diffuse answers suggest inspection without automatic repair", async () => {
|
|
17
|
+
using project = new TestProject();
|
|
18
|
+
const prepared = await project.prepare();
|
|
19
|
+
const insufficient = assess(prepared, choiceReply(CHOICE.insufficient));
|
|
20
|
+
expect(insufficient.uncertain).toBe(true);
|
|
21
|
+
expect(insufficient.concern).toBe(false);
|
|
22
|
+
const diffuse = choiceReply();
|
|
23
|
+
const answer = diffuse.answers.decision;
|
|
24
|
+
if (answer?.type !== "choice") throw new Error("Choice fixture is invalid");
|
|
25
|
+
answer.confidence = 0.2;
|
|
26
|
+
expect(assess(prepared, diffuse).action).toContain("suggests no repair or approval");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("Noul uncertainty cannot become an applicability waiver", async () => {
|
|
30
|
+
using project = new TestProject();
|
|
31
|
+
const prepared = { ...await project.prepare(), definition: CATALOG["review.rule_applicability"] };
|
|
32
|
+
const uncertain = assess(prepared, noulReply(0.5));
|
|
33
|
+
expect(uncertain.uncertain).toBe(true);
|
|
34
|
+
expect(uncertain.concern).toBe(false);
|
|
35
|
+
const applies = assess(prepared, noulReply(1));
|
|
36
|
+
expect(applies.action).toContain("owner still decides");
|
|
37
|
+
expect(assess(prepared, noulReply(0)).action).toContain("cannot suppress a finding");
|
|
38
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Assessment, Prepared } from "./types";
|
|
2
|
+
import type { Reply } from "./wire";
|
|
3
|
+
|
|
4
|
+
import { OrlyError } from "../model";
|
|
5
|
+
import { ADVICE_THRESHOLD, ASSERTION_ACTIONS, CHOICE, REPLY_KEY } from "./constants";
|
|
6
|
+
|
|
7
|
+
const UNCERTAIN_ACTION = "Inspect the selected context and supply any missing evidence. This uncertain answer suggests no repair or approval.";
|
|
8
|
+
|
|
9
|
+
export function assess(prepared: Prepared, reply: Reply): Assessment {
|
|
10
|
+
const answer = reply.answers[REPLY_KEY];
|
|
11
|
+
if (!answer) throw new OrlyError("Validated reply has no requested answer.");
|
|
12
|
+
if (answer.type === "noul") {
|
|
13
|
+
const yes = answer.noul >= 0.5;
|
|
14
|
+
const strength = yes ? answer.noul : 1 - answer.noul;
|
|
15
|
+
const uncertain = strength < ADVICE_THRESHOLD;
|
|
16
|
+
return {
|
|
17
|
+
decision: yes ? "yes" : "no", strength, uncertain,
|
|
18
|
+
concern: !uncertain && yes === prepared.definition.yesIsConcern,
|
|
19
|
+
action: uncertain ? UNCERTAIN_ACTION : yes ? prepared.definition.yesAction : prepared.definition.noAction,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const probability = answer.probabilities[answer.choice];
|
|
23
|
+
const action = Object.entries(ASSERTION_ACTIONS).find(([choice]) => choice === answer.choice)?.[1];
|
|
24
|
+
if (probability === undefined || action === undefined) throw new OrlyError("Answer has no cataloged follow-up action.");
|
|
25
|
+
const strength = Math.min(answer.confidence, probability);
|
|
26
|
+
const uncertain = strength < ADVICE_THRESHOLD || answer.choice === CHOICE.insufficient;
|
|
27
|
+
return {
|
|
28
|
+
decision: answer.choice, strength, uncertain,
|
|
29
|
+
concern: !uncertain && answer.choice !== CHOICE.exact,
|
|
30
|
+
action: uncertain ? UNCERTAIN_ACTION : action,
|
|
31
|
+
};
|
|
32
|
+
}
|