@agentsfleet/orly 0.10.1 → 0.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsfleet/orly",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Repository-scoped engineering harness: renders agent rules, materialises the gates that enforce them, and proves the PR boundary \u2014 for any coding agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
 
6
- import { CONFIG_PATH, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
6
+ import { CONFIG_PATH, contentDigest, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
7
7
  import { RulesModel } from "./model";
8
8
 
9
9
  const ROOT = resolve(import.meta.dir, "..");
@@ -156,6 +156,40 @@ describe("staleVersion", () => {
156
156
  });
157
157
  });
158
158
 
159
+ describe("managedDrift — content", () => {
160
+ test("a managed file edited after orly wrote it is drift", async () => {
161
+ // The gap this closes was not hypothetical: a consuming repository added a
162
+ // gradient ban and a font-ownership check straight to a materialised gate
163
+ // script, the pack never learned them, and the next update deleted them —
164
+ // with doctor green the whole way, because it asked only whether the file
165
+ // EXISTED. A gate whose checks can be removed under a passing verifier is
166
+ // not a gate.
167
+ const root = scratch();
168
+ const wrote = "check_one\ncheck_two\n";
169
+ writeFileSync(join(root, "audit.sh"), wrote);
170
+ const config = { ...(await seedConfig(root)), managed: ["audit.sh"], digests: { "audit.sh": contentDigest(new TextEncoder().encode(wrote)) } };
171
+ expect(managedDrift(root, config)).toEqual([]);
172
+
173
+ writeFileSync(join(root, "audit.sh"), "check_one\n");
174
+ const drift = managedDrift(root, config);
175
+ expect(drift).toHaveLength(1);
176
+ expect(drift[0]).toContain("audit.sh");
177
+ expect(drift[0]).toContain("edited after orly wrote it");
178
+ // Deliberately NOT "run orly update": the local content may be the better
179
+ // version, and update discards it. The source is where it has to go.
180
+ expect(drift[0]).toContain("move the change into the pack source");
181
+ });
182
+
183
+ test("a file with no recorded digest makes no claim", async () => {
184
+ // Two cases share this path: a config written before digests existed, and a
185
+ // hook orly manages but did not author. Neither may be reported as drift.
186
+ const root = scratch();
187
+ writeFileSync(join(root, "hook.sh"), "the repository's own\n");
188
+ const config = { ...(await seedConfig(root)), managed: ["hook.sh"], digests: {} };
189
+ expect(managedDrift(root, config)).toEqual([]);
190
+ });
191
+ });
192
+
159
193
  describe("managedDrift", () => {
160
194
  test("every managed file present reports no drift", async () => {
161
195
  const root = scratch();
@@ -223,6 +257,7 @@ describe("writeConfig", () => {
223
257
  commands: { conform: [["make", "audit"]] },
224
258
  surfaces: { user: ["src/"], docs: ["docs/"] },
225
259
  managed: ["AGENTS.md"],
260
+ digests: {},
226
261
  };
227
262
  await writeConfig(root, mine);
228
263
  await writeConfig(root, { ...(await readConfig(root))!, orly_version: "0.4.0", managed: ["AGENTS.md", "dispatch/write_rust.md"] });
package/src/config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { dirname, extname, join, relative } from "node:path";
3
4
 
@@ -16,6 +17,9 @@ const EXTENSIONS_FIELD = "extensions";
16
17
  const PACKS_FIELD = "packs";
17
18
  const VERSION_FIELD = "orly_version";
18
19
  const MANAGED_FIELD = "managed";
20
+ const DIGESTS_FIELD = "digests";
21
+ const DIGEST_ALGORITHM = "sha256";
22
+ const DIGEST_ENCODING = "hex";
19
23
  const COMMANDS_FIELD = "commands";
20
24
  const CONFORM_COMMAND = "conform";
21
25
  const MAKE_COMMAND = "make";
@@ -65,6 +69,7 @@ export type RepoConfig = {
65
69
  commands: Record<string, string[][]>;
66
70
  surfaces: JsonObject | undefined;
67
71
  managed: string[];
72
+ digests: Record<string, string>;
68
73
  };
69
74
 
70
75
  export function configPath(targetRoot: string): string {
@@ -109,9 +114,26 @@ function parseConfig(value: JsonObject): RepoConfig {
109
114
  commands: readCommands(value[COMMANDS_FIELD]),
110
115
  surfaces: isObject(value.surfaces) ? value.surfaces : undefined,
111
116
  managed: stringArray(value[MANAGED_FIELD] ?? [], `${CONFIG_PATH} ${MANAGED_FIELD}`),
117
+ digests: readDigests(value[DIGESTS_FIELD]),
112
118
  };
113
119
  }
114
120
 
121
+ // Absent in a config written before digests existed, and absent per-file for
122
+ // anything orly did not author (a hook it declined to overwrite). Both cases
123
+ // read as "no claim", which is what keeps an older checkout green until its
124
+ // next update rather than failing it for a record it never had.
125
+ function readDigests(value: unknown): Record<string, string> {
126
+ if (!isObject(value)) return {};
127
+ const digests: Record<string, string> = {};
128
+ for (const [path, digest] of Object.entries(value)) if (isString(digest)) digests[path] = digest;
129
+ return digests;
130
+ }
131
+
132
+ /// The digest orly records for content it wrote, and recomputes to check it.
133
+ export function contentDigest(bytes: Uint8Array): string {
134
+ return createHash(DIGEST_ALGORITHM).update(bytes).digest(DIGEST_ENCODING);
135
+ }
136
+
115
137
  // What orly installed here, and whether the engine has moved since.
116
138
  export function staleVersion(config: RepoConfig, installedVersion: string): string | undefined {
117
139
  if (config.orly_version === installedVersion) return undefined;
@@ -152,9 +174,33 @@ export function managedDrift(targetRoot: string, config: RepoConfig): string[] {
152
174
  ...present
153
175
  .filter((relativePath) => ignored.has(relativePath))
154
176
  .map((relativePath) => `managed file is ignored by git: ${relativePath} — orly wrote it and a clone will not receive it; scope the .gitignore rule that excludes it`),
177
+ ...editedManaged(targetRoot, config, present),
155
178
  ].sort();
156
179
  }
157
180
 
181
+ // A managed file whose CONTENT no longer matches what orly wrote.
182
+ //
183
+ // The half `managedDrift` was missing, and the gap was not theoretical: a
184
+ // consuming repository added two checks straight to a materialised gate script,
185
+ // the pack never learned them, and the next update deleted them — while doctor
186
+ // reported green throughout, because it verified that the file EXISTED and
187
+ // never that it still said what orly put there. A gate whose checks can be
188
+ // removed under a passing verifier is not a gate.
189
+ //
190
+ // The remedy for a hit is deliberately not `orly update`: the local content may
191
+ // be the better version, as it was in that case. Update overwrites it; the
192
+ // message says to move the change into the source instead, which is the only
193
+ // way it reaches every other consumer.
194
+ function editedManaged(targetRoot: string, config: RepoConfig, present: string[]): string[] {
195
+ return present.flatMap((relativePath) => {
196
+ const recorded = config.digests[relativePath];
197
+ if (!recorded) return [];
198
+ const actual = contentDigest(readFileSync(join(targetRoot, relativePath)));
199
+ if (actual === recorded) return [];
200
+ return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or \`orly update\` to discard it`];
201
+ });
202
+ }
203
+
158
204
  // Key order is fixed so a rewrite produces a reviewable diff: orly's own two
159
205
  // fields sit at the ends, the repository's three in the middle, untouched.
160
206
  export async function writeConfig(targetRoot: string, config: RepoConfig): Promise<string> {
@@ -168,6 +214,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
168
214
  commands: config.commands,
169
215
  ...(config.surfaces ? { surfaces: config.surfaces } : {}),
170
216
  managed: [...config.managed].sort(),
217
+ digests: Object.fromEntries(Object.entries(config.digests).sort(([a], [b]) => a.localeCompare(b))),
171
218
  };
172
219
  await Bun.write(path, `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`);
173
220
  return path;
@@ -177,7 +224,7 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
177
224
  // from what the repository already builds with; an empty set is honest rather
178
225
  // than wrong, and says so in the gate's own failure message.
179
226
  export async function seedConfig(targetRoot: string): Promise<RepoConfig> {
180
- return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [] };
227
+ return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [], digests: {} };
181
228
  }
182
229
 
183
230
  // What this repository installed and runs with, read from its own `.oracle/`.
package/src/install.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSy
2
2
  import { dirname, extname, join, relative, resolve } from "node:path";
3
3
 
4
4
  import { UNSCOPED_ENVIRONMENT } from "./git_env";
5
- import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
5
+ import { CONFIG_PATH, contentDigest, readConfig, seedConfig, selectPacks, writeConfig } from "./config";
6
6
  import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
7
7
  import { AGENTS_FILENAME, GENERATED_BANNER, installLoaders, Layout, loaderTargets, ORLY_AGENTS_FILENAME } from "./loaders";
8
8
  import { referenceClosureErrors, renderProfileText } from "./references";
@@ -116,7 +116,16 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
116
116
  skipped.push(...hooks.all.filter((path) => !hooks.written.includes(path)));
117
117
  // One file carries both halves: the repository's own fields pass through
118
118
  // untouched, orly's two record what this run installed and wrote.
119
- await writeConfig(targetRoot, { ...config, orly_version: options.orlyVersion, managed: [...planned.map((file) => file.target), ...hooks.all] });
119
+ // Digests cover `planned` only. Hooks are managed — orly restores a missing
120
+ // one — but it does not always AUTHOR them: it refuses to overwrite a hook it
121
+ // did not write. Recording a digest for a file whose content belongs to the
122
+ // repository would report the repository's own edits as drift.
123
+ await writeConfig(targetRoot, {
124
+ ...config,
125
+ orly_version: options.orlyVersion,
126
+ managed: [...planned.map((file) => file.target), ...hooks.all],
127
+ digests: Object.fromEntries(planned.map((file) => [file.target, contentDigest(file.content)])),
128
+ });
120
129
  if (!existing) written.push(CONFIG_PATH);
121
130
  return { ok: true, packs, written: written.sort(), skipped: skipped.sort(), errors: [] };
122
131
  }
@@ -370,6 +379,11 @@ function retargetRulesCitations(text: string, orlyFile: string): string {
370
379
  return text
371
380
  .replaceAll(`](${AGENTS_FILENAME})`, `](${orlyFile})`)
372
381
  .replaceAll(`](../${AGENTS_FILENAME})`, `](../${orlyFile})`)
382
+ // The relative spelling first: `../AGENTS.md` contains `AGENTS.md`, so the
383
+ // bare rule below would rewrite its tail and leave the `../` stranded.
384
+ // Missing it is what rendered `[`../AGENTS.md`](../AGENTS.orly.md)` into
385
+ // four managed pages — a label naming one file over a link to another.
386
+ .replaceAll(`\`../${AGENTS_FILENAME}\``, `\`../${orlyFile}\``)
373
387
  .replaceAll(`\`${AGENTS_FILENAME}\``, `\`${orlyFile}\``);
374
388
  }
375
389