@agentsfleet/orly 0.7.1 → 0.9.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/AGENTS.md +16 -11
- package/README.md +8 -1
- package/SOUL.md +4 -1
- package/audits/agents-md.md +3 -0
- package/audits/rust-error.sh +228 -0
- package/core/operating-model.md +13 -11
- package/dispatch/lib.sh +28 -3
- package/dispatch/lifecycle.md +27 -0
- package/dispatch/verify.md +45 -11
- package/dispatch/write_go.md +8 -0
- package/dispatch/write_rust.md +166 -0
- package/dispatch/write_rust.sh +38 -0
- package/docs/EXECUTE_DOC_READS.md +1 -1
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/docs/VERIFY_TIERS.md +35 -11
- package/docs/greptile-learnings/RULES.md +1 -0
- package/package.json +1 -1
- package/packs/language/rust/rules.md +9 -0
- package/registry.json +16 -0
- package/skills/orly-babysit-prs/SKILL.md +4 -8
- package/skills/orly-spec-new/SKILL.md +4 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -6
- package/skills/orly-write-unit-test/SKILL.md +4 -6
- package/src/cli.ts +11 -1
- package/src/criteria.test.ts +17 -6
- package/src/criteria.ts +34 -9
- package/src/gates.test.ts +33 -9
- package/src/gates_spec.test.ts +1 -1
- package/src/install.ts +18 -72
- package/src/install_packs.test.ts +56 -0
- package/src/loaders.test.ts +148 -0
- package/src/loaders.ts +240 -0
- package/src/render.test.ts +59 -1
- package/src/verify.test.ts +72 -2
- package/src/verify.ts +44 -3
package/src/loaders.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { assertWritableInside, isObject } from "./model";
|
|
5
|
+
|
|
6
|
+
// How orly's rules reach an agent's context, as opposed to how they reach the
|
|
7
|
+
// disk. Installing the file is not delivering it: every runtime decides for
|
|
8
|
+
// itself what it loads at session start, and only one of the four this
|
|
9
|
+
// repository grades against loads AGENTS.md unprompted. A pointer an agent has
|
|
10
|
+
// to notice and follow is a hope; an import a runtime resolves is a mechanism.
|
|
11
|
+
// So the rules land beside a small set of loaders, one per runtime that has a
|
|
12
|
+
// deterministic way in, and the runtimes with no way in are covered by the
|
|
13
|
+
// read-record gate instead.
|
|
14
|
+
//
|
|
15
|
+
// Every file here belongs to the repository, not to orly — they are the same
|
|
16
|
+
// class as the pointer host: written when absent, edited only inside a
|
|
17
|
+
// delimited block, never listed in `managed`, and never replaced wholesale
|
|
18
|
+
// once someone else has written one.
|
|
19
|
+
|
|
20
|
+
export const AGENTS_FILENAME = "AGENTS.md";
|
|
21
|
+
export const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
|
|
22
|
+
export const GENERATED_BANNER = "> **Generated by `orly`.**";
|
|
23
|
+
export const POINTER_HOST_KIND = "pointer host";
|
|
24
|
+
|
|
25
|
+
const CLAUDE_FILENAME = "CLAUDE.md";
|
|
26
|
+
const OPENCODE_FILENAME = "opencode.json";
|
|
27
|
+
const OPENCODE_SCHEMA = "https://opencode.ai/config.json";
|
|
28
|
+
const INSTRUCTIONS_KEY = "instructions";
|
|
29
|
+
const LOADER_KIND = "runtime loader";
|
|
30
|
+
const POINTER_OPEN = "<!-- orly:begin -->";
|
|
31
|
+
const POINTER_CLOSE = "<!-- orly:end -->";
|
|
32
|
+
const NEWLINE = "\n";
|
|
33
|
+
const JSON_INDENT = 2;
|
|
34
|
+
|
|
35
|
+
export type Layout = { orlyFile: string; pointerHost?: string };
|
|
36
|
+
export type LoaderResult = { written: string[]; skipped: string[] };
|
|
37
|
+
|
|
38
|
+
// What one loader destination came to. WRITTEN — orly created or refreshed it.
|
|
39
|
+
// CURRENT — already exactly right, by orly's hand or by someone else's wiring.
|
|
40
|
+
// THEIRS — the repository answered this question itself; leave it alone.
|
|
41
|
+
const WRITTEN = "written";
|
|
42
|
+
const CURRENT = "current";
|
|
43
|
+
const THEIRS = "theirs";
|
|
44
|
+
type Verdict = typeof WRITTEN | typeof CURRENT | typeof THEIRS;
|
|
45
|
+
|
|
46
|
+
// Every destination orly aims at a file it does not own, with the kind name
|
|
47
|
+
// the refusal will carry. Exported so the install can guard them in its
|
|
48
|
+
// up-front pass: a committed symlink escaping the repository must be refused
|
|
49
|
+
// before anything is staged, not discovered at the write.
|
|
50
|
+
export function loaderTargets(layout: Layout): Array<[string, string]> {
|
|
51
|
+
const targets: Array<[string, string]> = [[CLAUDE_FILENAME, LOADER_KIND], [OPENCODE_FILENAME, LOADER_KIND]];
|
|
52
|
+
if (layout.pointerHost) targets.unshift([layout.pointerHost, POINTER_HOST_KIND]);
|
|
53
|
+
return targets;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Run after the managed files land, so a repository whose install refused for
|
|
57
|
+
// any other reason never carries a loader pointing at rules that were not
|
|
58
|
+
// written.
|
|
59
|
+
export async function installLoaders(targetRoot: string, layout: Layout): Promise<LoaderResult> {
|
|
60
|
+
const written: string[] = [];
|
|
61
|
+
const skipped: string[] = [];
|
|
62
|
+
const record = (path: string, verdict: Verdict) => (verdict === WRITTEN ? written : skipped).push(path);
|
|
63
|
+
|
|
64
|
+
// The host is what a loader imports: the repository's own file, which the
|
|
65
|
+
// pointer block chains onward to orly's. Importing orly's file directly
|
|
66
|
+
// would deliver the harness and drop the repository's own rules, which the
|
|
67
|
+
// block itself says outrank it.
|
|
68
|
+
const host = layout.pointerHost ?? layout.orlyFile;
|
|
69
|
+
if (layout.pointerHost) record(layout.pointerHost, await upsertPointer(targetRoot, layout.pointerHost, layout.orlyFile));
|
|
70
|
+
record(CLAUDE_FILENAME, await upsertClaudeLoader(targetRoot, host));
|
|
71
|
+
record(OPENCODE_FILENAME, await upsertOpencodeLoader(targetRoot, [...new Set([host, layout.orlyFile])]));
|
|
72
|
+
return { written, skipped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The one edit orly makes to a file it does not own. Delimited so a re-run
|
|
76
|
+
// replaces exactly what the last run wrote and nothing either side of it, and
|
|
77
|
+
// so a user who wants orly out can delete the block by sight.
|
|
78
|
+
//
|
|
79
|
+
// The bare `@<file>` line is the load-bearing half: Claude Code resolves it as
|
|
80
|
+
// an import, recursively, so the rules arrive with the file instead of waiting
|
|
81
|
+
// for an agent to follow a link. It must stay unbackticked and alone on its
|
|
82
|
+
// line — an import inside a code span stays literal text, which is exactly how
|
|
83
|
+
// this block spent its first release delivering nothing.
|
|
84
|
+
function pointerBlock(orlyFile: string): string {
|
|
85
|
+
return [
|
|
86
|
+
POINTER_OPEN,
|
|
87
|
+
`**Engineering harness:** read [\`${orlyFile}\`](${orlyFile}) as well — it carries the safety rules,`,
|
|
88
|
+
"the dispatch router that names which rule page to read before which edit, and the lifecycle",
|
|
89
|
+
"this repository gates on. Where the two disagree, this file wins.",
|
|
90
|
+
"",
|
|
91
|
+
"The line below is an import, not decoration: a runtime that resolves it loads those rules",
|
|
92
|
+
"with this file, and one that does not still has the link above.",
|
|
93
|
+
"",
|
|
94
|
+
`@${orlyFile}`,
|
|
95
|
+
POINTER_CLOSE,
|
|
96
|
+
].join(NEWLINE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// What a repository with no rules of its own starts with: the pointer, and a
|
|
100
|
+
// visible invitation to write here. Without the second line the stub reads as
|
|
101
|
+
// an orly artifact and nobody touches it — which is the trap the single-file
|
|
102
|
+
// layout had, moved one file over.
|
|
103
|
+
function stubHost(block: string): string {
|
|
104
|
+
return [
|
|
105
|
+
"# Repository rules",
|
|
106
|
+
"",
|
|
107
|
+
block,
|
|
108
|
+
"",
|
|
109
|
+
"Write this repository's own rules below. orly never edits this file",
|
|
110
|
+
"outside the block above, so nothing here is lost to an `orly update`.",
|
|
111
|
+
].join(NEWLINE);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function upsertPointer(targetRoot: string, host: string, orlyFile: string): Promise<Verdict> {
|
|
115
|
+
// Re-checked at the write, as every other destination is: the up-front
|
|
116
|
+
// refusal pass guards the plan, this guards the target that could have
|
|
117
|
+
// changed underneath the run.
|
|
118
|
+
assertWritableInside(targetRoot, host, POINTER_HOST_KIND);
|
|
119
|
+
const path = join(targetRoot, host);
|
|
120
|
+
const block = pointerBlock(orlyFile);
|
|
121
|
+
// No file, or one orly generated under an older layout where it owned this
|
|
122
|
+
// name. Neither holds a line the repository wrote, so both become the stub —
|
|
123
|
+
// which is the migration for anything installed before the split.
|
|
124
|
+
if (!existsSync(path) || (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) {
|
|
125
|
+
await Bun.write(path, `${stubHost(block)}\n`);
|
|
126
|
+
return WRITTEN;
|
|
127
|
+
}
|
|
128
|
+
const current = await Bun.file(path).text();
|
|
129
|
+
const open = current.indexOf(POINTER_OPEN);
|
|
130
|
+
const close = current.indexOf(POINTER_CLOSE);
|
|
131
|
+
if (open >= 0 && close > open) {
|
|
132
|
+
const replaced = `${current.slice(0, open)}${block}${current.slice(close + POINTER_CLOSE.length)}`;
|
|
133
|
+
if (replaced === current) return CURRENT;
|
|
134
|
+
await Bun.write(path, replaced);
|
|
135
|
+
return WRITTEN;
|
|
136
|
+
}
|
|
137
|
+
await Bun.write(path, `${current.replace(/\s*$/, "")}\n\n${block}\n`);
|
|
138
|
+
return WRITTEN;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Claude Code auto-loads CLAUDE.md and nothing else — an AGENTS.md reaches it
|
|
142
|
+
// only through an import or a symlink someone made by hand. Without this file
|
|
143
|
+
// the promise that a teammate's clone is governed with nothing installed is
|
|
144
|
+
// false in the runtime most of them are running.
|
|
145
|
+
//
|
|
146
|
+
// Written ONLY where the repository has none. A CLAUDE.md that already exists
|
|
147
|
+
// is an answer its author gave to this exact question, and the common form of
|
|
148
|
+
// that answer is a symlink to AGENTS.md — where a delimited edit would land in
|
|
149
|
+
// the host file and import it into itself. Recognising the link as delivery
|
|
150
|
+
// and leaving every other CLAUDE.md alone keeps one rule for both: orly writes
|
|
151
|
+
// this file or it does not touch it.
|
|
152
|
+
async function upsertClaudeLoader(targetRoot: string, host: string): Promise<Verdict> {
|
|
153
|
+
assertWritableInside(targetRoot, CLAUDE_FILENAME, LOADER_KIND);
|
|
154
|
+
const path = join(targetRoot, CLAUDE_FILENAME);
|
|
155
|
+
const link = linkVerdict(targetRoot, path, host);
|
|
156
|
+
if (link) return link;
|
|
157
|
+
const content = claudeLoader(host);
|
|
158
|
+
if (!existsSync(path)) {
|
|
159
|
+
await Bun.write(path, content);
|
|
160
|
+
return WRITTEN;
|
|
161
|
+
}
|
|
162
|
+
const current = await Bun.file(path).text();
|
|
163
|
+
if (current === content) return CURRENT;
|
|
164
|
+
// An older loader of orly's own making is orly's to refresh; anything else
|
|
165
|
+
// is the repository's file and is reported, not edited.
|
|
166
|
+
if (!current.startsWith(GENERATED_BANNER)) return THEIRS;
|
|
167
|
+
await Bun.write(path, content);
|
|
168
|
+
return WRITTEN;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function claudeLoader(host: string): string {
|
|
172
|
+
return [
|
|
173
|
+
GENERATED_BANNER,
|
|
174
|
+
"> Claude Code loads this file at session start and no other, so this is where",
|
|
175
|
+
`> this repository's rules get in. The import below pulls in \`${host}\`, and`,
|
|
176
|
+
"> whatever that file imports in turn — orly's rules among them.",
|
|
177
|
+
">",
|
|
178
|
+
"> Delete this file to opt out; orly writes it only when it is missing, and",
|
|
179
|
+
"> never edits a CLAUDE.md it did not write.",
|
|
180
|
+
"",
|
|
181
|
+
`@${host}`,
|
|
182
|
+
"",
|
|
183
|
+
].join(NEWLINE);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// A CLAUDE.md symlinked at the host is the other supported wiring, and it
|
|
187
|
+
// delivers the same bytes: the runtime reads the link and lands in the file
|
|
188
|
+
// the import chain starts from. Resolving both sides matters on macOS, where
|
|
189
|
+
// the repository itself routinely sits behind a link.
|
|
190
|
+
//
|
|
191
|
+
// Any other link is still someone's deliberate wiring, and is left alone for
|
|
192
|
+
// the reason the whole loader is: a write through a symlink lands in the file
|
|
193
|
+
// it points at, not the one orly named. Undefined = not a link, decide on
|
|
194
|
+
// content.
|
|
195
|
+
function linkVerdict(targetRoot: string, path: string, host: string): Verdict | undefined {
|
|
196
|
+
try {
|
|
197
|
+
if (!lstatSync(path).isSymbolicLink()) return undefined;
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return realpathSync(path) === realpathSync(join(targetRoot, host)) ? CURRENT : THEIRS;
|
|
203
|
+
} catch {
|
|
204
|
+
return THEIRS;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// opencode loads nothing by default: instructions are the paths its config
|
|
209
|
+
// names, so a rules file it was never told about is a rules file it never
|
|
210
|
+
// reads. Both entries are listed because opencode has no auto-load to fall
|
|
211
|
+
// back on — naming only orly's half would deliver the harness in this runtime
|
|
212
|
+
// and the repository's own rules in every other one.
|
|
213
|
+
//
|
|
214
|
+
// An existing config is edited by adding what is missing and nothing else. A
|
|
215
|
+
// config orly cannot parse is left exactly as it is: rewriting a file whose
|
|
216
|
+
// shape we do not understand is how an install eats someone's settings.
|
|
217
|
+
async function upsertOpencodeLoader(targetRoot: string, entries: string[]): Promise<Verdict> {
|
|
218
|
+
assertWritableInside(targetRoot, OPENCODE_FILENAME, LOADER_KIND);
|
|
219
|
+
const path = join(targetRoot, OPENCODE_FILENAME);
|
|
220
|
+
if (!existsSync(path)) {
|
|
221
|
+
await Bun.write(path, serialise({ $schema: OPENCODE_SCHEMA, [INSTRUCTIONS_KEY]: entries }));
|
|
222
|
+
return WRITTEN;
|
|
223
|
+
}
|
|
224
|
+
let config: unknown;
|
|
225
|
+
try {
|
|
226
|
+
config = JSON.parse(await Bun.file(path).text());
|
|
227
|
+
} catch {
|
|
228
|
+
return THEIRS;
|
|
229
|
+
}
|
|
230
|
+
if (!isObject(config)) return THEIRS;
|
|
231
|
+
const listed = Array.isArray(config[INSTRUCTIONS_KEY]) ? config[INSTRUCTIONS_KEY].map(String) : [];
|
|
232
|
+
const missing = entries.filter((entry) => !listed.includes(entry));
|
|
233
|
+
if (missing.length === 0) return CURRENT;
|
|
234
|
+
await Bun.write(path, serialise({ ...config, [INSTRUCTIONS_KEY]: [...listed, ...missing] }));
|
|
235
|
+
return WRITTEN;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function serialise(config: object): string {
|
|
239
|
+
return `${JSON.stringify(config, undefined, JSON_INDENT)}\n`;
|
|
240
|
+
}
|
package/src/render.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ const KERNEL_PACKS = ["universal.authoring", "language.zig", "language.rust", "d
|
|
|
15
15
|
// This repository's own selection, as `.oracle/orly.json` declares it — the
|
|
16
16
|
// root render must stay current against exactly what `orly update` writes.
|
|
17
17
|
const DOTFILES_PACKS = ["universal.authoring", "language.zig", "language.typescript", "language.javascript", "language.rust", "language.go", "language.python", "language.shell", "language.mdx", "domain.sql", "domain.http", "domain.auth", "domain.documentation", "domain.changelog", "workflow.specifications", "workflow.governance", "product.agentsfleet", "persona.indy", "workflow.skills"];
|
|
18
|
-
const DOTFILES_COMMANDS = { conform: [["make", "
|
|
18
|
+
const DOTFILES_COMMANDS = { conform: [["make", "conform"]], "verify.unit": [["bun", "test", "src"]] };
|
|
19
19
|
|
|
20
20
|
describe("Renderer", () => {
|
|
21
21
|
test("renders byte-stable text for a pack set", async () => {
|
|
@@ -58,3 +58,61 @@ describe("Renderer", () => {
|
|
|
58
58
|
expect(new Renderer(model).renderText(KERNEL_PACKS)).rejects.toThrow("unclosed orly pack block");
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
// Claims the rendered rules make about the machine. Each one was false when
|
|
63
|
+
// this suite was written: the prose said "always" where the gate runs once,
|
|
64
|
+
// named a chain where one gate is the boundary, made a conditional skill
|
|
65
|
+
// mandatory, and credited a reporting-only criterion with enforcement. Prose
|
|
66
|
+
// drifting back is exactly as expensive as the first time, and none of it is
|
|
67
|
+
// visible to a render-determinism check.
|
|
68
|
+
describe("the rendered rules match the machine", () => {
|
|
69
|
+
test("VERIFY states two cadences rather than one 'always'", async () => {
|
|
70
|
+
const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
|
|
71
|
+
|
|
72
|
+
expect(text).toContain("Two cadences, one boundary");
|
|
73
|
+
expect(text).toContain("runs ONCE, at the milestone boundary");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("CHORE(close) names the pr gate, not the whole chain", async () => {
|
|
77
|
+
const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
|
|
78
|
+
|
|
79
|
+
expect(text).toContain("`orly gate pr` is the command CHORE(close) runs");
|
|
80
|
+
// Sound without re-running the fast tier, and the reason is stated where
|
|
81
|
+
// the claim is: git.pushed pins HEAD to what pre-push already graded.
|
|
82
|
+
expect(text).toContain("`git.pushed` proves HEAD is exactly what the pre-push");
|
|
83
|
+
expect(text).toContain("One gate per cadence, each tier run once");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the integration skill is conditional on a real input/output boundary", async () => {
|
|
87
|
+
const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
|
|
88
|
+
|
|
89
|
+
expect(text).toContain("when the diff crosses a module boundary with real input/output");
|
|
90
|
+
expect(text).not.toContain("both before the PR, never skipped");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// persona.indy is opt-in, and five engineering clauses were hiding inside it:
|
|
94
|
+
// a repository dropping the voice silently dropped symlink-edit routing,
|
|
95
|
+
// dotfiles backup, vault naming, sibling-repo precedent, and the
|
|
96
|
+
// read-the-reference mandate with it. A pack named for voice must cost only
|
|
97
|
+
// voice when it is left out.
|
|
98
|
+
test("the engineering clauses survive without the persona pack", async () => {
|
|
99
|
+
const renderer = new Renderer(await RulesModel.load(ROOT));
|
|
100
|
+
const withoutPersona = await renderer.renderText(DOTFILES_PACKS.filter((pack) => pack !== "persona.indy"), DOTFILES_COMMANDS);
|
|
101
|
+
|
|
102
|
+
expect(withoutPersona).toContain("Symlinked edits land in the repository that owns the file");
|
|
103
|
+
expect(withoutPersona).toContain("timestamped backup");
|
|
104
|
+
expect(withoutPersona).toContain("Vault names come from the environment");
|
|
105
|
+
expect(withoutPersona).toContain("Check a sibling repository");
|
|
106
|
+
expect(withoutPersona).toContain("Read the reference implementation before designing");
|
|
107
|
+
// What persona is actually for stays behind with it.
|
|
108
|
+
expect(withoutPersona).not.toContain("Indy's reference checkouts");
|
|
109
|
+
expect(withoutPersona).not.toContain("🤠 Indy");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("SOUL does not claim the language criterion enforces anything", async () => {
|
|
113
|
+
const soul = await Bun.file(resolve(ROOT, "SOUL.md")).text();
|
|
114
|
+
|
|
115
|
+
expect(soul).toContain("REPORTS it");
|
|
116
|
+
expect(soul).not.toContain("`orly gate verify` enforces it");
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/verify.test.ts
CHANGED
|
@@ -1,13 +1,83 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
3
5
|
|
|
4
6
|
import { RulesModel } from "./model";
|
|
5
|
-
import { verifyRenders } from "./verify";
|
|
7
|
+
import { packSourceErrors, verifyRenders } from "./verify";
|
|
6
8
|
|
|
7
9
|
const ROOT = resolve(import.meta.dir, "..");
|
|
10
|
+
const PACK = "language.demo";
|
|
11
|
+
const SOURCE = "packs/demo/rules.md";
|
|
12
|
+
const TARGET = "dispatch/write_demo.md";
|
|
13
|
+
const BODY = "# Demo\n\nOne rule, stated once.\n";
|
|
14
|
+
|
|
15
|
+
// A checkout carrying one pack whose source and target are different paths —
|
|
16
|
+
// the shape `planFiles` never writes into, and therefore the only shape that
|
|
17
|
+
// can drift.
|
|
18
|
+
function sandbox(target?: string): { model: RulesModel; root: string } {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "orly-packs-"));
|
|
20
|
+
mkdirSync(join(root, "packs/demo"), { recursive: true });
|
|
21
|
+
writeFileSync(join(root, SOURCE), BODY);
|
|
22
|
+
if (target !== undefined) {
|
|
23
|
+
mkdirSync(join(root, "dispatch"), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, TARGET), target);
|
|
25
|
+
}
|
|
26
|
+
const registry = { packs: { [PACK]: { extensions: [], managed_files: [{ source: SOURCE, target: TARGET }] } } };
|
|
27
|
+
return { model: new RulesModel(root, registry), root };
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
test("every profile renders deterministically", async () => {
|
|
10
31
|
const checks = await verifyRenders(await RulesModel.load(ROOT));
|
|
11
32
|
|
|
12
33
|
expect(checks.filter((check) => check.name.endsWith(".idempotent")).every((check) => check.result === "pass")).toBeTrue();
|
|
13
34
|
});
|
|
35
|
+
|
|
36
|
+
test("pack targets match their sources", async () => {
|
|
37
|
+
const model = await RulesModel.load(ROOT);
|
|
38
|
+
const local = Object.keys(model.registry.packs as Record<string, unknown>).sort();
|
|
39
|
+
|
|
40
|
+
expect(await packSourceErrors(model, local)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("pack target drift names the pack and both paths", async () => {
|
|
44
|
+
const { model, root } = sandbox(`${BODY}and a second rule the source never gained.\n`);
|
|
45
|
+
try {
|
|
46
|
+
const findings = await packSourceErrors(model, [PACK]);
|
|
47
|
+
|
|
48
|
+
expect(findings).toHaveLength(1);
|
|
49
|
+
expect(findings[0]).toContain(TARGET);
|
|
50
|
+
expect(findings[0]).toContain(SOURCE);
|
|
51
|
+
expect(findings[0]).toContain(PACK);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a pack target the checkout does not carry is not drift", async () => {
|
|
58
|
+
const { model, root } = sandbox();
|
|
59
|
+
try {
|
|
60
|
+
expect(await packSourceErrors(model, [PACK])).toEqual([]);
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(root, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The trailing newline managedContent appends is part of what a consumer
|
|
67
|
+
// receives, so a target missing it is drift like any other byte.
|
|
68
|
+
test("a matched pack target reports clean", async () => {
|
|
69
|
+
const { model, root } = sandbox(BODY);
|
|
70
|
+
try {
|
|
71
|
+
expect(await packSourceErrors(model, [PACK])).toEqual([]);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(root, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("verify reports pack source parity beside the render proofs", async () => {
|
|
78
|
+
const checks = await verifyRenders(await RulesModel.load(ROOT));
|
|
79
|
+
const parity = checks.find((check) => check.name === "packs.sources.current");
|
|
80
|
+
|
|
81
|
+
expect(parity).toBeDefined();
|
|
82
|
+
expect(parity?.result).toBe("pass");
|
|
83
|
+
});
|
package/src/verify.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { localSelection } from "./config";
|
|
5
|
-
import {
|
|
5
|
+
import { managedContent } from "./install";
|
|
6
|
+
import { hashContent, isString, objectArray, objectValue, RulesModel } from "./model";
|
|
6
7
|
import { Renderer } from "./render";
|
|
7
8
|
|
|
8
9
|
const PASS_RESULT = "pass";
|
|
9
10
|
const FAIL_RESULT = "fail";
|
|
11
|
+
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
12
|
+
const AGENTS_FILENAME = "AGENTS.md";
|
|
13
|
+
const DETAIL_SEPARATOR = "; ";
|
|
10
14
|
|
|
11
15
|
export type VerificationCheck = {
|
|
12
16
|
name: string;
|
|
@@ -32,10 +36,47 @@ export async function verifyRenders(model: RulesModel): Promise<VerificationChec
|
|
|
32
36
|
});
|
|
33
37
|
}
|
|
34
38
|
const errors = await renderer.rootErrors(local.packs, local.commands);
|
|
35
|
-
checks.push({ name: "generated.root.current", result: errors.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: errors.join(
|
|
39
|
+
checks.push({ name: "generated.root.current", result: errors.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: errors.join(DETAIL_SEPARATOR) });
|
|
40
|
+
const drift = await packSourceErrors(model, local.packs);
|
|
41
|
+
checks.push({ name: "packs.sources.current", result: drift.length === 0 ? PASS_RESULT : FAIL_RESULT, detail: drift.join(DETAIL_SEPARATOR) });
|
|
36
42
|
return checks;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// Every pack file this checkout carries, against the bytes its pack would ship.
|
|
46
|
+
//
|
|
47
|
+
// `planFiles` deliberately skips writing a managed file into the checkout that
|
|
48
|
+
// owns its source: writing there would either replace a source with its own
|
|
49
|
+
// pack-filtered rendering or plant a second copy that drifts. The consequence is
|
|
50
|
+
// that the copy living here is maintained BY HAND and no install ever corrects
|
|
51
|
+
// it — so a source can gain three sections while the target beside it keeps the
|
|
52
|
+
// old two, and every consumer receives rules this repository's own agents, its
|
|
53
|
+
// dispatch-coverage audit, and its rule ledger never see. Both halves of that
|
|
54
|
+
// had already happened when this check was written.
|
|
55
|
+
//
|
|
56
|
+
// A target the checkout does not carry is silence, not drift: a consumer-only
|
|
57
|
+
// path (a skill copied into `.claude/skills/`) is absent here by design.
|
|
58
|
+
export async function packSourceErrors(model: RulesModel, packs: string[]): Promise<string[]> {
|
|
59
|
+
const registryPacks = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
|
|
60
|
+
const known = new Set(Object.keys(registryPacks));
|
|
61
|
+
const findings = new Set<string>();
|
|
62
|
+
for (const name of packs) {
|
|
63
|
+
const pack = objectValue(registryPacks[name], `pack ${name}`);
|
|
64
|
+
for (const entry of objectArray(pack.managed_files, `pack ${name} managed_files`)) {
|
|
65
|
+
if (!isString(entry.source) || !isString(entry.target)) continue;
|
|
66
|
+
// Same path on both sides is the source itself — nothing to compare it to.
|
|
67
|
+
if (entry.source === entry.target) continue;
|
|
68
|
+
const targetPath = join(model.root, entry.target);
|
|
69
|
+
if (!existsSync(targetPath)) continue;
|
|
70
|
+
// The engine checkout renders its own rules under their own name, so the
|
|
71
|
+
// citation retarget managedContent applies for a guest is identity here.
|
|
72
|
+
const expected = await managedContent(join(model.root, entry.source), entry.target, entry.source, packs, known, AGENTS_FILENAME);
|
|
73
|
+
if (hashContent(expected) === hashContent(await Bun.file(targetPath).bytes())) continue;
|
|
74
|
+
findings.add(`${entry.target} drifted from ${entry.source} (pack ${name}) — copy the source over it`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...findings].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
export async function writeEvidence(
|
|
40
81
|
model: RulesModel,
|
|
41
82
|
profile: string,
|