@agentsfleet/orly 0.10.5 → 0.10.7

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.
@@ -8,6 +8,8 @@ import {
8
8
  newRepository, newSpecRepository, orly, SPEC_RELATIVE, specFixture,
9
9
  } from "./gates_test_support";
10
10
 
11
+ const BASELINE_FIXTURE = "**Test Baseline:** unit=0 integration=0";
12
+
11
13
  afterEach(cleanupTemporaryDirectories);
12
14
 
13
15
  describe("spec discovery", () => {
@@ -16,7 +18,7 @@ describe("spec discovery", () => {
16
18
  const model = await modelFor(project);
17
19
  git(project, "checkout", "-q", "-b", "fix/adhoc");
18
20
 
19
- const verify = runGate(model, project, "verify");
21
+ const verify = runGate(model, project, "pr");
20
22
  expect(verify.results.find((result) => result.name === "spec.dimensions")?.detail).toContain("no active spec");
21
23
  expect(runGate(model, project, "work").results.find((result) => result.name === "cmd.conform")?.ok).toBeTrue();
22
24
  });
@@ -122,6 +124,43 @@ describe("closed-spec follow-through", () => {
122
124
  expect(pr.results.find((result) => result.name === "spec.baseline")?.ok).toBeTrue();
123
125
  });
124
126
 
127
+ // CHORE(open) declares the header and the boundary fills it. The gate that
128
+ // grades the boundary is `pr`, so `pending` surviving to here is the
129
+ // measurement skipped rather than the measurement not yet due.
130
+ test("a Test Baseline still reading `pending` is red at the Pull Request gate", async () => {
131
+ const project = newRepository();
132
+ const model = await modelFor(project);
133
+ git(project, "checkout", "-q", "-b", "feat/pending-baseline");
134
+ mkdirSync(join(project, "docs/v1/active"), { recursive: true });
135
+ await Bun.write(
136
+ join(project, SPEC_RELATIVE),
137
+ specFixture("IN_PROGRESS", "feat/pending-baseline").replace(BASELINE_FIXTURE, "**Test Baseline:** pending — measured before the Pull Request"),
138
+ );
139
+ git(project, "add", ".");
140
+ git(project, "commit", "-q", "-m", "chore(open): the spec declares its baseline");
141
+
142
+ const baseline = runGate(model, project, "pr").results.find((result) => result.name === "spec.baseline");
143
+ expect(baseline?.ok).toBeFalse();
144
+ expect(baseline?.detail).toContain("carries no count");
145
+ });
146
+
147
+ test("a branch carrying no code records n/a and spec.baseline is green", async () => {
148
+ const project = newRepository();
149
+ const model = await modelFor(project);
150
+ git(project, "checkout", "-q", "-b", "feat/no-code-baseline");
151
+ mkdirSync(join(project, "docs/v1/active"), { recursive: true });
152
+ await Bun.write(
153
+ join(project, SPEC_RELATIVE),
154
+ specFixture("IN_PROGRESS", "feat/no-code-baseline").replace(BASELINE_FIXTURE, "**Test Baseline:** n/a — no code on this branch"),
155
+ );
156
+ git(project, "add", ".");
157
+ git(project, "commit", "-q", "-m", "chore(open): a docs-only stream measures nothing");
158
+
159
+ const baseline = runGate(model, project, "pr").results.find((result) => result.name === "spec.baseline");
160
+ expect(baseline?.ok).toBeTrue();
161
+ expect(baseline?.detail).toContain("not applicable");
162
+ });
163
+
125
164
  test("Status: DONE while the spec still lives under active/ is red on spec.moved", async () => {
126
165
  const project = newRepository();
127
166
  const model = await modelFor(project);
@@ -10,6 +10,10 @@ import { CONFIG_PATH } from "./config";
10
10
  import { RulesModel } from "./model";
11
11
 
12
12
  const GIT = "git";
13
+ const HEAD = "HEAD";
14
+ const REV_PARSE = "rev-parse";
15
+ const IN_PROGRESS = "IN_PROGRESS";
16
+ const BASELINE_EVIDENCE = "**Baseline evidence:** README.md";
13
17
  const QUIET = "-q";
14
18
  const COMMIT = "commit";
15
19
  const CONFIG = "config";
@@ -21,6 +25,7 @@ const ACCEPTANCE_RUBRIC_HEADING = "Acceptance Rubric";
21
25
  const FIXTURE = "fixture";
22
26
  const AUDITS_DIR = "audits";
23
27
  const SPEC_GATE_SCRIPT = `${AUDITS_DIR}/spec-template.sh`;
28
+ const SPEC_GATE_CONTENT = `${AUDITS_DIR}/spec-template.ts`;
24
29
 
25
30
  export const ROOT = resolve(import.meta.dir, "..");
26
31
  export const SPEC_RELATIVE = "docs/v1/active/M99_001_P2_CLI_FIXTURE.md";
@@ -105,7 +110,10 @@ export function closedSpecRepository(branch: string, extraLines: string[] = []):
105
110
  mkdirSync(join(project, "docs/v1/done"), { recursive: true });
106
111
  mkdirSync(join(project, AUDITS_DIR), { recursive: true });
107
112
  copyFileSync(join(ROOT, SPEC_GATE_SCRIPT), join(project, SPEC_GATE_SCRIPT));
108
- Bun.write(join(project, "docs/v1/done/M99_001_P2_CLI_FIXTURE.md"), specFixture("DONE", branch, extraLines));
113
+ copyFileSync(join(ROOT, SPEC_GATE_CONTENT), join(project, SPEC_GATE_CONTENT));
114
+ Bun.write(join(project, "docs/v1/done/M99_001_P2_CLI_FIXTURE.md"), specFixture("DONE", branch, [
115
+ `**Baseline revision:** ${gitOutput(project, REV_PARSE, HEAD)}`, BASELINE_EVIDENCE, ...extraLines,
116
+ ]));
109
117
  git(project, ADD, ".");
110
118
  git(project, COMMIT, QUIET, MESSAGE, "chore: close the fixture spec");
111
119
  return project;
@@ -116,7 +124,10 @@ export function newSpecRepository(): string {
116
124
  mkdirSync(join(project, "docs/v1/active"), { recursive: true });
117
125
  mkdirSync(join(project, AUDITS_DIR), { recursive: true });
118
126
  copyFileSync(join(ROOT, SPEC_GATE_SCRIPT), join(project, SPEC_GATE_SCRIPT));
119
- Bun.write(join(project, SPEC_RELATIVE), specFixture());
127
+ copyFileSync(join(ROOT, SPEC_GATE_CONTENT), join(project, SPEC_GATE_CONTENT));
128
+ Bun.write(join(project, SPEC_RELATIVE), specFixture(IN_PROGRESS, undefined, [
129
+ `**Baseline revision:** ${gitOutput(project, REV_PARSE, HEAD)}`, BASELINE_EVIDENCE,
130
+ ]));
120
131
  git(project, ADD, ".");
121
132
  git(project, COMMIT, QUIET, MESSAGE, "test: add fixture spec");
122
133
  return project;
@@ -124,7 +135,7 @@ export function newSpecRepository(): string {
124
135
 
125
136
  // A spec carrying every section audits/spec-template.sh requires, with all
126
137
  // Dimensions already DONE so the gates' own criteria are what is under test.
127
- export function specFixture(status = "IN_PROGRESS", branch?: string, extraLines: string[] = []): string {
138
+ export function specFixture(status = IN_PROGRESS, branch?: string, extraLines: string[] = []): string {
128
139
  const sections = [
129
140
  "PR Intent & comprehension handshake",
130
141
  "Overview",
@@ -139,6 +150,7 @@ export function specFixture(status = "IN_PROGRESS", branch?: string, extraLines:
139
150
  "Metrics & Observability",
140
151
  "Test Specification (tiered)",
141
152
  ACCEPTANCE_RUBRIC_HEADING,
153
+ "Dead Code Sweep",
142
154
  "Out of Scope",
143
155
  "Product Clarity (authoring record)",
144
156
  "Decomposition & alternatives",
@@ -149,7 +161,9 @@ export function specFixture(status = "IN_PROGRESS", branch?: string, extraLines:
149
161
  // Family 2b) — the fixture models a compliant spec, so its rubric carries
150
162
  // them the way a real spec's S-rows do.
151
163
  heading === ACCEPTANCE_RUBRIC_HEADING
152
- ? `## ${heading}\n\n| S1 | Conform gates green | \`${TRUE_COMMAND}\` | exit 0 | P0 | |\n| S2 | Unit tests pass | \`${TRUE_COMMAND}\` | exit 0 | P0 | |\n`
164
+ ? `## ${heading}\n\n| # | Criterion | Verify | Expected | Priority | Graded |\n|---|---|---|---|---|---|\n| S1 | Declared checks | \`${TRUE_COMMAND}\` | exit 0 | P0 | |\n`
165
+ : heading.startsWith("Implementing agent") ? `## ${heading}\n\n1. \`README.md\` — fixture reference.\n`
166
+ : heading.startsWith("Test Specification") ? `## ${heading}\n\n| Dimension | Tier | Test | Asserts |\n|---|---|---|---|\n| 1.1 | unit | \`fixture_test\` | Valid input is accepted. |\n`
153
167
  : `## ${heading}\n\nFixture content for ${heading}.\n`,
154
168
  );
155
169
  return [
package/src/install.ts CHANGED
@@ -44,10 +44,8 @@ export type InstallOptions = {
44
44
 
45
45
  type PlannedFile = { target: string; content: Uint8Array; mode: string };
46
46
 
47
- // Materialise a profile into a repository: its rules, the gates that enforce
48
- // them, the hooks that run the gates, and a lock recording exactly what landed.
49
- // Everything is staged in a temporary directory first, so a run that refuses —
50
- // or dies — leaves the target untouched rather than half-installed.
47
+ // Validate the planned files before writing them. Later filesystem failures
48
+ // can still leave a partial installation; inspect the diff before retrying.
51
49
  export async function install(model: RulesModel, options: InstallOptions): Promise<InstallResult> {
52
50
  const targetRoot = resolve(options.targetRoot);
53
51
  requireWorkTree(targetRoot);
@@ -78,7 +76,7 @@ export async function install(model: RulesModel, options: InstallOptions): Promi
78
76
  // tracked anything — this checkout among them. Without it, orly's own
79
77
  // render reads as a stranger's file and every update refuses.
80
78
  if (file.target === layout.orlyFile && (await Bun.file(path).text()).startsWith(GENERATED_BANNER)) { written.push(file.target); continue; }
81
- refusals.push(refusal(file.target, false));
79
+ refusals.push(refusal(file.target));
82
80
  }
83
81
  if (options.installHooks) {
84
82
  const claim = hooksClaimedByAnother(targetRoot);
@@ -161,35 +159,19 @@ async function hookAuthorship(targetRoot: string, target: string, gate: string,
161
159
  return { path: target, message: "a hook already exists here and orly did not write it", suggestion: "move it aside, re-run with --force to replace it, or use --no-hooks" };
162
160
  }
163
161
 
164
- function refusal(target: string, wasManaged: boolean): InstallError {
165
- return wasManaged
166
- ? { path: target, message: "managed file was edited in place since it was installed", suggestion: "revert the edit, or re-run with --force to overwrite it" }
167
- : { path: target, message: "a file already exists here and orly did not write it", suggestion: "move it aside, or re-run with --force to replace it" };
162
+ function refusal(target: string): InstallError {
163
+ return { path: target, message: "a file already exists here and orly did not write it", suggestion: "move it aside, or re-run with --force to replace it" };
168
164
  }
169
165
 
170
- // The payload is written to a scratch directory and only moved into the target
171
- // once every file exists and every reference it names resolves. Reference
172
- // closure runs against the staged tree, so a pack whose façade cites a document
173
- // no selected pack provides is caught before anything lands. The scratch
174
- // directory lives inside the target's own .oracle/ — not the OS tmp dir —
175
- // because `rename(2)` cannot cross a filesystem boundary: an OS tmp dir and
176
- // the target repository are routinely on different filesystems (a mounted
177
- // external drive, a devcontainer's bind-mounted workspace over a tmpfs
178
- // /tmp), and staging there turned every such install into a hard EXDEV
179
- // crash instead of the atomic move this function exists to guarantee.
166
+ // Check references in the staged payload. Stage inside the repository because
167
+ // rename cannot move files across filesystem boundaries.
180
168
  async function stageAndCommit(model: RulesModel, targetRoot: string, planned: PlannedFile[], written: string[]): Promise<InstallError[]> {
181
169
  const stagingParent = join(targetRoot, dirname(CONFIG_PATH));
182
170
  const stagingParentExisted = existsSync(stagingParent);
183
171
  mkdirSync(stagingParent, { recursive: true });
184
172
  const stage = mkdtempSync(join(stagingParent, STAGE_PREFIX));
185
173
 
186
- // A refusal must leave the target exactly as it found it, and staging now
187
- // lives inside it: remove .oracle/ too if this run is the one that created
188
- // it and it is still empty on the failure path — a fresh empty directory
189
- // left behind is as much a footprint as a written file. The success path
190
- // must NOT run this: .oracle/ is still empty at this point (the caller
191
- // writes orly.json into it right after stageAndCommit returns), so an
192
- // unconditional check would delete a directory the next step needs.
174
+ // On failure, remove an empty .oracle/ only if this call created it.
193
175
  const cleanupStagingParentIfEmpty = () => {
194
176
  rmSync(stage, { recursive: true, force: true });
195
177
  if (!stagingParentExisted && existsSync(stagingParent) && readdirSync(stagingParent).length === 0) rmSync(stagingParent, { recursive: true, force: true });
@@ -202,11 +184,7 @@ async function stageAndCommit(model: RulesModel, targetRoot: string, planned: Pl
202
184
  await Bun.write(path, file.content);
203
185
  applyMode(path, file.mode);
204
186
  }
205
- // Skills are playbooks copied verbatim, not rule pages: one names the
206
- // façade for every language it might be invoked in. Grading those citations
207
- // would force a Go repository to take the Zig and TypeScript packs to
208
- // satisfy a menu it will never read — the same reason a `docs/TEMPLATE.md`
209
- // comment is exempt.
187
+ // Skills may name optional language guides that this repository does not use.
210
188
  const markdown = planned
211
189
  .filter((file) => extname(file.target) === MARKDOWN_EXTENSION && !file.target.includes(SKILLS_SEGMENT))
212
190
  .map((file) => join(stage, file.target));
@@ -232,10 +210,7 @@ async function stageAndCommit(model: RulesModel, targetRoot: string, planned: Pl
232
210
  }
233
211
  }
234
212
 
235
- // A hooksPath already pointing somewhere other than what init installs is
236
- // someone else's setup — retargeting it silently would disable whatever
237
- // ran there before. init only overrides its own prior installs (the same
238
- // directory name) or an unset config, matching the idempotent-rerun case.
213
+ // Retargeting another tool's hooks would silently disable its checks.
239
214
  function hooksClaimedByAnother(targetRoot: string): InstallError | undefined {
240
215
  const result = Bun.spawnSync([GIT_COMMAND, GIT_REPO_FLAG, targetRoot, GIT_CONFIG_SUBCOMMAND, "--get", HOOKS_PATH_KEY], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, env: UNSCOPED_ENVIRONMENT });
241
216
  if (result.exitCode !== 0) return undefined;
@@ -244,10 +219,7 @@ function hooksClaimedByAnother(targetRoot: string): InstallError | undefined {
244
219
  return { path: HOOKS_PATH_KEY, message: `already set to '${existing}', not the directory this install manages`, suggestion: "run with --no-hooks to leave it alone, or --force to retarget it" };
245
220
  }
246
221
 
247
- // Hooks are generated, never copied: this repository's own hooks run `make
248
- // audit` and `bin/orly verify`, neither of which exists in a consumer. What a
249
- // consumer needs is the gate engine, reached through the binary that installed
250
- // it — with a PATH fallback so a relocated checkout still resolves.
222
+ // Consumer hooks use the installed executable, independent of this checkout.
251
223
  async function installHooks(targetRoot: string): Promise<{ written: string[]; all: string[] }> {
252
224
  for (const [name] of HOOK_GATES) assertWritableInside(targetRoot, `${HOOKS_DIRECTORY}/${name}`, HOOK_KIND);
253
225
  const directory = join(targetRoot, HOOKS_DIRECTORY);
@@ -279,7 +251,7 @@ function hookScript(gate: string): string {
279
251
  " GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES",
280
252
  "",
281
253
  'if ! command -v orly >/dev/null 2>&1; then',
282
- ' printf "orly: not on PATH — install it with `bun add -g @agentsfleet/orly`, or delete this hook\\n" >&2',
254
+ " printf '%s\\n' 'orly: not on PATH — install it with `bun add -g @agentsfleet/orly`' >&2",
283
255
  " exit 1",
284
256
  "fi",
285
257
  "",
@@ -304,12 +276,7 @@ async function planFiles(model: RulesModel, packs: string[], commands: Record<st
304
276
  if (claimed && claimed !== entry.source) throw new OrlyError(`packs disagree on ${entry.target}: ${claimed} and ${entry.source}`);
305
277
  sources.set(entry.target, entry.source);
306
278
  const path = join(model.root, entry.source);
307
- // Installing into the checkout that owns the sources. Copying is for
308
- // consumers: here the repository already has the file, and writing it
309
- // would either replace a pack source with its own pack-filtered
310
- // rendering (same path) or plant a second copy that drifts from the
311
- // original (skills/ into .claude/skills/). Skipping is what the retired
312
- // sync verb used to buy, generalised past the same-path case.
279
+ // Never overwrite the engine's pack sources with filtered consumer copies.
313
280
  if (resolved(model.root) === resolved(targetRoot)) continue;
314
281
  planned.set(entry.target, { target: entry.target, content: await managedContent(path, entry.target, entry.source, packs, known, orlyFile), mode: modeLabel(path) });
315
282
  }
@@ -319,30 +286,9 @@ async function planFiles(model: RulesModel, packs: string[], commands: Record<st
319
286
  return [...planned.values()].sort((left, right) => left.target.localeCompare(right.target));
320
287
  }
321
288
 
322
- // Where orly's rules land, and whether the repository keeps a file of its own.
323
- //
324
- // A repository that already wrote an AGENTS.md keeps it, under its own name,
325
- // byte for byte — orly's rules go beside it as AGENTS.orly.md and are reached
326
- // by a pointer block. Renaming their file would put orly's name on content it
327
- // did not write and move a file its own docs already cite. A repository with
328
- // no AGENTS.md gets orly's rules under that name directly, because nothing is
329
- // displaced and agent runtimes auto-load it.
330
- //
331
- // The test is the generated banner, not the config's managed list: this
332
- // checkout's own .oracle/orly.json predates managed tracking, so a
333
- // managed-based test would treat orly's own render as a stranger's file and
334
- // push it aside on the next update.
335
289
  function resolveLayout(model: RulesModel, targetRoot: string): Layout {
336
- // The checkout that authors the rules holds them as sources, not as an
337
- // installed copy — the same reason pack files inside the target are skipped
338
- // rather than written. Its AGENTS.md IS the render, and the payload ships it
339
- // as the reference copy.
290
+ // The engine owns its render; consumers retain their own AGENTS.md.
340
291
  if (resolved(model.root) === resolved(targetRoot)) return { orlyFile: AGENTS_FILENAME };
341
- // Everywhere else orly is a guest. It always takes AGENTS.orly.md and always
342
- // leaves AGENTS.md to the repository, whether or not the repository has
343
- // written one yet. One layout, so `update` never has to ask which mode it is
344
- // in, and a repository that starts with no rules of its own still has the
345
- // file to put them in when it wants them.
346
292
  return { orlyFile: ORLY_AGENTS_FILENAME, pointerHost: AGENTS_FILENAME };
347
293
  }
348
294
 
@@ -354,14 +300,7 @@ function resolved(path: string): string {
354
300
  }
355
301
  }
356
302
 
357
- // Managed markdown is pack-filtered on the way in, exactly as a rendered core
358
- // document is. Copying it raw is how a Rust crate ends up holding a rule that
359
- // points at a Zig façade it will never receive.
360
- //
361
- // Exported because it is the definition of what bytes belong at a managed
362
- // target, and `orly verify` asks exactly that question of the checkout that
363
- // authors the sources — where no install ever writes, so nothing else would
364
- // catch a hand-edited copy drifting from the source every consumer receives.
303
+ // Installation and verification use the same filtered bytes for each pack.
365
304
  export async function managedContent(path: string, target: string, source: string, packs: string[], known: Set<string>, orlyFile: string): Promise<Uint8Array> {
366
305
  const bytes = await Bun.file(path).bytes();
367
306
  if (extname(target) !== MARKDOWN_EXTENSION) return bytes;
@@ -369,11 +308,7 @@ export async function managedContent(path: string, target: string, source: strin
369
308
  return new TextEncoder().encode(`${retargetRulesCitations(filtered, orlyFile)}\n`);
370
309
  }
371
310
 
372
- // A rule page citing `AGENTS.md` means orly's rules, which is that file's name
373
- // only in the checkout that authors them. In a consumer they live in
374
- // AGENTS.orly.md and AGENTS.md belongs to the repository, so a citation left
375
- // alone would point every reader at the wrong half — and fail reference
376
- // closure, since the repository's own file is not orly's to promise.
311
+ // Managed rule citations must point to Orly's file, not the consumer's own rules.
377
312
  function retargetRulesCitations(text: string, orlyFile: string): string {
378
313
  if (orlyFile === AGENTS_FILENAME) return text;
379
314
  return text
@@ -66,21 +66,19 @@ describe("Renderer", () => {
66
66
  // drifting back is exactly as expensive as the first time, and none of it is
67
67
  // visible to a render-determinism check.
68
68
  describe("the rendered rules match the machine", () => {
69
- test("VERIFY states two cadences rather than one 'always'", async () => {
69
+ test("VERIFY distinguishes Section proof from final verification", async () => {
70
70
  const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
71
71
 
72
- expect(text).toContain("Two cadences, one boundary");
73
- expect(text).toContain("runs ONCE, at the milestone boundary");
72
+ expect(text).toContain("Section checks prove the changed Dimensions");
73
+ expect(text).toContain("the final gate runs the full declared verification set");
74
74
  });
75
75
 
76
76
  test("CHORE(close) names the pr gate, not the whole chain", async () => {
77
77
  const text = await new Renderer(await RulesModel.load(ROOT)).renderText(DOTFILES_PACKS, DOTFILES_COMMANDS);
78
78
 
79
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");
80
+ expect(text).toContain("a push is never proof that tests ran");
81
+ expect(text).toContain("every declared `verify.*` command itself");
84
82
  });
85
83
 
86
84
  test("the integration skill is conditional on a real input/output boundary", async () => {
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, existsSync, symlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { CONFIG_PATH, readConfig } from "./config";
6
+ import { runGate } from "./gates";
7
+ import { cleanupTemporaryDirectories, git, newRepository, orly, ROOT, temporaryDirectory } from "./gates_test_support";
8
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
9
+ import { RulesModel } from "./model";
10
+
11
+ const CONFORM = "conform";
12
+ const VERIFY_DOCS = "verify.docs";
13
+ const TRUE = [["true"]];
14
+ const CONFIG_CRITERION = "repo.config";
15
+ const GUIDE = "guide";
16
+ const LANGUAGE_CASES = [
17
+ ["rs", "rust"], ["zig", "zig"], ["py", "python"], ["ts", "typescript"], ["go", "go"],
18
+ ] as const;
19
+
20
+ afterEach(cleanupTemporaryDirectories);
21
+
22
+ describe("setup completeness", () => {
23
+ for (const [extension, language] of LANGUAGE_CASES) {
24
+ test(`${language} init reports missing commands without guessing a toolchain`, async () => {
25
+ const root = newRepository();
26
+ await Bun.write(join(root, `source.${extension}`), "\n");
27
+ const installed = orly(root, ROOT, "init", "--json");
28
+ expect(installed.code).toBe(0);
29
+ const result = JSON.parse(installed.output);
30
+ expect(result.packs).toContain(`language.${language}`);
31
+ expect(result.setupErrors).toHaveLength(2);
32
+ expect(result.setupErrors.join(" ")).toContain(CONFORM);
33
+ expect(result.setupErrors.join(" ")).toContain("verify.*");
34
+ const doctor = orly(root, ROOT, "doctor");
35
+ expect(doctor.code).toBe(1);
36
+ expect(doctor.output).toContain("setup incomplete");
37
+ });
38
+ }
39
+
40
+ test("text output explains how to finish setup", () => {
41
+ const installed = orly(newRepository(), ROOT, "init");
42
+ expect(installed.code).toBe(0);
43
+ expect(installed.output).toContain("setup incomplete");
44
+ expect(installed.output).toContain("orly doctor");
45
+ });
46
+
47
+ for (const commands of [{}, { build: TRUE }, { [CONFORM]: TRUE }, { [VERIFY_DOCS]: TRUE }, { [CONFORM]: TRUE, "verify.": TRUE }]) {
48
+ test(`gates reject incomplete command roles: ${Object.keys(commands).join(", ") || "empty"}`, async () => {
49
+ const root = newRepository();
50
+ await Bun.write(join(root, CONFIG_PATH), JSON.stringify({ schema_version: 1, commands }));
51
+ const model = await RulesModel.load(ROOT);
52
+ for (const gate of ["work", "verify", "pr"] as const) {
53
+ const result = runGate(model, root, gate).results.find((entry) => entry.name === CONFIG_CRITERION);
54
+ expect(result?.ok).toBe(false);
55
+ expect(result?.detail).toContain("declare");
56
+ }
57
+ });
58
+ }
59
+ });
60
+
61
+ describe("documentation repositories", () => {
62
+ for (const extension of ["md", "mdx"]) {
63
+ test(`${extension} repositories run their own checks without application test lanes`, async () => {
64
+ const root = newRepository();
65
+ const page = `${GUIDE}.${extension}`;
66
+ await Bun.write(join(root, page), "# Guide\n\nRead this page.\n");
67
+ await Bun.write(join(root, "Makefile"), `test:\n\t@test -s ${page}\nlint:\n\t@test -s ${page}\n`);
68
+ const commands = { [CONFORM]: [["make", "test"]], [VERIFY_DOCS]: [["make", "lint"]] };
69
+ await Bun.write(join(root, CONFIG_PATH), JSON.stringify({ schema_version: 1, commands }));
70
+ const installed = orly(root, ROOT, "init", "--json");
71
+ expect(installed.code).toBe(0);
72
+ const result = JSON.parse(installed.output);
73
+ expect(result.setupErrors).toEqual([]);
74
+ expect(result.packs).toContain("domain.documentation");
75
+ expect(result.packs.includes("language.mdx")).toBe(extension === "mdx");
76
+ expect((await readConfig(root))?.commands).toEqual(commands);
77
+ expect(orly(root, ROOT, "doctor").code).toBe(0);
78
+
79
+ // Use a fresh executable directory, as a global package installation would.
80
+ const binaries = temporaryDirectory();
81
+ symlinkSync(join(ROOT, "bin/orly"), join(binaries, "orly"));
82
+ symlinkSync(process.execPath, join(binaries, "bun"));
83
+ git(root, "add", ".");
84
+ const commit = Bun.spawnSync(["/usr/bin/git", "commit", "-qm", "docs: initialize checks"], {
85
+ cwd: root, env: { ...UNSCOPED_ENVIRONMENT, PATH: `${binaries}:/usr/bin:/bin`, ORLY_TELEMETRY_OFF: "1" }, stdout: "pipe", stderr: "pipe",
86
+ });
87
+ expect({ code: commit.exitCode, output: commit.stderr.toString() }).toEqual({ code: 0, output: expect.any(String) });
88
+
89
+ const model = await RulesModel.load(ROOT);
90
+ const checks = () => runGate(model, root, "pr").results.filter((entry) => entry.name.startsWith("cmd."));
91
+ expect(checks().map((entry) => [entry.name, entry.ok])).toEqual([[`cmd.${VERIFY_DOCS}`, true]]);
92
+ await Bun.write(join(root, page), "");
93
+ expect(orly(root, ROOT, "gate", "work").code).toBe(1);
94
+ expect(checks().map((entry) => [entry.name, entry.ok])).toEqual([[`cmd.${VERIFY_DOCS}`, false]]);
95
+ });
96
+ }
97
+ });
98
+
99
+ test("a missing executable prints installation guidance without running an installer", async () => {
100
+ const root = newRepository();
101
+ expect(orly(root, ROOT, "init").code).toBe(0);
102
+ const binaries = temporaryDirectory();
103
+ const marker = join(binaries, "installer-ran");
104
+ const installer = join(binaries, "bun");
105
+ await Bun.write(installer, '#!/bin/sh\nprintf ran > "$ORLY_TEST_MARKER"\n');
106
+ chmodSync(installer, 0o755);
107
+
108
+ for (const hook of ["pre-commit", "pre-push"]) {
109
+ const result = Bun.spawnSync(["/bin/bash", `.githooks/${hook}`], {
110
+ cwd: root, env: { ...process.env, PATH: binaries, ORLY_TEST_MARKER: marker }, stdout: "pipe", stderr: "pipe",
111
+ });
112
+ expect(result.exitCode).toBe(1);
113
+ expect(result.stderr.toString()).toContain("`bun add -g @agentsfleet/orly`");
114
+ expect(existsSync(marker)).toBe(false);
115
+ }
116
+ });
@@ -0,0 +1,96 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+
4
+ import type { CriterionContext } from "./criteria_support";
5
+ import { specBaseline } from "./criteria_spec";
6
+ import { cleanupTemporaryDirectories, git, gitOutput, modelFor, newRepository } from "./gates_test_support";
7
+
8
+ const CONFIG_PATH = ".oracle/orly.json";
9
+ const UNIT = "**Test Baseline:** unit=12 integration=4";
10
+ const EVIDENCE = "**Baseline evidence:** README.md";
11
+ const NO_CODE = "**Test Baseline:** n/a — no code on this branch";
12
+
13
+ afterEach(cleanupTemporaryDirectories);
14
+
15
+ async function fixture(): Promise<CriterionContext> {
16
+ const root = newRepository();
17
+ const model = await modelFor(root, undefined, { "verify.integration": [["true"]] });
18
+ const revision = gitOutput(root, "rev-parse", "HEAD");
19
+ git(root, "checkout", "-b", "feat/baseline");
20
+ await Bun.write(join(root, "changed.rs"), "pub const VALUE: u8 = 1;\n");
21
+ git(root, "add", ".");
22
+ git(root, "commit", "-m", "test: code change");
23
+ return { root, model, acceptDirty: false, specPath: "docs/v1/active/baseline.md", specText: `${UNIT}\n**Baseline revision:** ${revision}\n${EVIDENCE}` };
24
+ }
25
+
26
+ describe("baseline evidence requirements", () => {
27
+ test("records declared counts and an existing comparison commit and report", async () => {
28
+ const context = await fixture();
29
+ expect(specBaseline().evaluate(context)).toEqual({ name: "spec.baseline", ok: true, detail: "baseline counts, comparison revision, and evidence reference recorded; report contents require review" });
30
+ });
31
+
32
+ test.each([
33
+ [UNIT, "**Test Baseline:** pending", "no count for unit"],
34
+ [UNIT, "**Test Baseline:** unit=12 integration=pending", "no count for integration"],
35
+ [UNIT, "**Test Baseline:** unit=12oops integration=4", "no count for unit"],
36
+ [UNIT, NO_CODE, "n/a cannot replace"],
37
+ [EVIDENCE, "**Baseline evidence:** missing-report.txt", "existing report"],
38
+ [EVIDENCE, "", "existing report"],
39
+ ])("rejects incomplete evidence: %s → %s", async (old, replacement, finding) => {
40
+ const context = await fixture();
41
+ context.specText = (context.specText ?? "").replace(old, replacement);
42
+ const verdict = specBaseline().evaluate(context);
43
+ expect(verdict.ok).toBeFalse();
44
+ expect(verdict.detail).toContain(finding);
45
+ });
46
+
47
+ test("a missing header cannot disappear into an empty spec", async () => {
48
+ const context = await fixture();
49
+ context.specText = "# Spec without baseline";
50
+ expect(specBaseline().evaluate(context).detail).toContain("no Test Baseline header");
51
+ });
52
+
53
+ test("a run URL is a reference for review, not a claim of fetched evidence", async () => {
54
+ const context = await fixture();
55
+ context.specText = (context.specText ?? "").replace(EVIDENCE, "**Baseline evidence:** https://example.com/runs/123");
56
+ expect(specBaseline().evaluate(context).ok).toBeTrue();
57
+ });
58
+
59
+ test("a revision must be a full existing ancestor", async () => {
60
+ const context = await fixture();
61
+ const text = context.specText ?? "";
62
+ for (const revision of ["main", "0".repeat(40)]) {
63
+ context.specText = text.replace(/\*\*Baseline revision:\*\* .+/, `**Baseline revision:** ${revision}`);
64
+ expect(specBaseline().evaluate(context).ok).toBeFalse();
65
+ }
66
+ });
67
+
68
+ test("a documentation branch can explain why no baseline applies", async () => {
69
+ const context = await fixture();
70
+ context.surfaces = { changed: ["notes.md"], code: [], docs: [], userSurface: [] };
71
+ context.specText = NO_CODE;
72
+ expect(specBaseline().evaluate(context).ok).toBeTrue();
73
+ });
74
+
75
+ test("repositories without baseline lanes use an explicit reason", async () => {
76
+ const context = await fixture();
77
+ await Bun.write(join(context.root, CONFIG_PATH), JSON.stringify({ schema_version: 1, commands: { "verify.custom": [["true"]] } }));
78
+ expect(specBaseline().evaluate(context).detail).toContain("no baseline lanes declared");
79
+ context.specText = "**Test Baseline:** n/a — no unit or integration lane declared";
80
+ expect(specBaseline().evaluate(context).ok).toBeTrue();
81
+ });
82
+
83
+ test("missing configuration cannot validate a claimed baseline", async () => {
84
+ const context = await fixture();
85
+ const root = newRepository();
86
+ expect(specBaseline().evaluate({ ...context, root }).detail).toContain("repository's declared commands");
87
+ });
88
+
89
+ test("malformed configuration reports a failure without aborting the gate", async () => {
90
+ const context = await fixture();
91
+ await Bun.write(join(context.root, CONFIG_PATH), "{");
92
+ const verdict = specBaseline().evaluate(context);
93
+ expect(verdict.ok).toBeFalse();
94
+ expect(verdict.detail).toContain("cannot read baseline configuration");
95
+ });
96
+ });