@agentsfleet/orly 0.10.4 → 0.10.6

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/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
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { validateSpec } from "../audits/spec-template";
4
+ import { specFixture } from "./gates_test_support";
5
+
6
+ const COMMANDS = { conform: [["true"]], "verify.unit": [["true"]] };
7
+ const VALID = specFixture();
8
+ const MAPPING = "| 1.1 | unit | `fixture_test` | Valid input is accepted. |";
9
+ const REFERENCE = "1. `README.md` — fixture reference.";
10
+ const RUBRIC_ROW = "| S1 | Declared checks | `true` | exit 0 | P0 | |";
11
+ const EXISTS = () => true;
12
+
13
+ describe("spec content readiness", () => {
14
+ test("a filled spec passes without product-specific commands or paths", () => {
15
+ expect(validateSpec(VALID, COMMANDS, EXISTS)).toEqual([]);
16
+ });
17
+
18
+ test.each([
19
+ ["empty section", "Fixture content for Overview.", "", "empty required section"],
20
+ ["missing reference", REFERENCE, "No references.", "read-first section needs"],
21
+ ["absolute reference", REFERENCE, "1. `/etc/hosts` — outside the repository.", "repository file"],
22
+ ["parent reference", REFERENCE, "1. `../other.md` — outside.", "repository file"],
23
+ ["unresolved reference", REFERENCE, "1. `docs/{name}.md` — incomplete.", "repository file"],
24
+ ["missing test", MAPPING, "", "no matching tiered test"],
25
+ ["wrong test", MAPPING, MAPPING.replace("fixture_test", "other_test"), "no matching tiered test"],
26
+ ["missing assertion", MAPPING, MAPPING.replace("Valid input is accepted.", ""), "no matching tiered test"],
27
+ ["missing tier", MAPPING, MAPPING.replace("unit", ""), "no matching tiered test"],
28
+ ["orphan test", MAPPING, MAPPING.replace("1.1", "2.1"), "unknown Dimension"],
29
+ ["missing columns", "| Dimension | Tier | Test | Asserts |", "| Wrong | Tier | Test | Asserts |", "needs Dimension"],
30
+ ["missing proof name", "→ Test `fixture_test`", "→ decide later", "every Dimension"],
31
+ ["command suffix", RUBRIC_ROW, RUBRIC_ROW.replace("`true`", "`true-other`"), "misses declared command"],
32
+ ["missing expected", RUBRIC_ROW, RUBRIC_ROW.replace("exit 0", ""), "misses declared command"],
33
+ ["missing command column", "| # | Criterion | Verify | Expected | Priority | Graded |", "| # | Criterion | Wrong | Expected | Priority | Graded |", "needs Verify"],
34
+ ])("rejects %s", (_name, old, replacement, finding) => {
35
+ expect(validateSpec(VALID.replace(old, replacement), COMMANDS, EXISTS).join("\n")).toContain(finding);
36
+ });
37
+
38
+ test.each(["{{fill:goal}}", "{unresolved_goal}", "<!-- tpl: delete me -->", "path/to/file.ext"])("rejects authoring residue %s", (slot) => {
39
+ expect(validateSpec(`${VALID}\n${slot}`, COMMANDS, EXISTS)).toContain("unfilled template placeholder or authoring guidance");
40
+ });
41
+
42
+ test("comments and fenced headings cannot supply required sections", () => {
43
+ const removed = VALID.replace("## Overview\n\nFixture content for Overview.", "");
44
+ const text = `${removed}\n<!--\n## Overview\nHidden.\n-->\n\n\`\`\`\n## Overview\nFenced.\n\`\`\``;
45
+ expect(validateSpec(text, COMMANDS, EXISTS).join("\n")).toContain("empty required section: ^Overview");
46
+ });
47
+
48
+ test.each(["````", "~~~"])("a %s code fence cannot supply a required heading", (fence) => {
49
+ const removed = VALID.replace("## Overview\n\nFixture content for Overview.", "");
50
+ const text = `${removed}\n${fence}\n\`\`\`\n## Overview\nStill inside code.\n${fence}\n`;
51
+ expect(validateSpec(text, COMMANDS, EXISTS).join("\n")).toContain("empty required section: ^Overview");
52
+ });
53
+
54
+ test("a quoted heading cannot supply a required section", () => {
55
+ const removed = VALID.replace("## Overview\n\nFixture content for Overview.", "");
56
+ expect(validateSpec(`${removed}\n> ## Overview\n> Quoted example.\n`, COMMANDS, EXISTS).join("\n")).toContain("empty required section: ^Overview");
57
+ });
58
+
59
+ test("dimensions inside code do not count as implementation requirements", () => {
60
+ const dimension = "- **Dimension 1.1** — DONE — fixture behaviour → Test `fixture_test`";
61
+ const text = VALID.replace(dimension, `\`\`\`\n${dimension}\n\`\`\``);
62
+ expect(validateSpec(text, COMMANDS, EXISTS).join("\n")).toContain("every Dimension");
63
+ });
64
+
65
+ test("comments containing a command do not satisfy its rubric row", () => {
66
+ const text = VALID.replace(RUBRIC_ROW, "<!-- true -->");
67
+ expect(validateSpec(text, COMMANDS, EXISTS).join("\n")).toContain("misses declared command verbatim: true");
68
+ });
69
+
70
+ test("missing files fail while URLs and file fragments resolve", () => {
71
+ expect(validateSpec(VALID, COMMANDS, () => false).join("\n")).toContain("pointer does not resolve");
72
+ const text = VALID.replace(REFERENCE, "1. https://example.com/reference — upstream.\n2. `README.md#example` — local.");
73
+ expect(validateSpec(text, COMMANDS, (path) => path === "README.md")).toEqual([]);
74
+ });
75
+
76
+ test("duplicate dimensions fail even when they share a test", () => {
77
+ const dimension = "- **Dimension 1.1** — DONE — fixture behaviour → Test `fixture_test`";
78
+ expect(validateSpec(VALID.replace(dimension, `${dimension}\n${dimension}`), COMMANDS, EXISTS)).toContain("Dimension identifiers must be unique");
79
+ });
80
+
81
+ test("all declared lanes are required for a source change", () => {
82
+ const commands = { ...COMMANDS, "verify.integration": [["cargo", "test", "--test", "system"]], "verify.lint": [["check", "style"]] };
83
+ const text = VALID.replace("Fixture content for Files Changed (blast radius).", "`lib/widget.rs` | EDIT | Implement behavior.");
84
+ const errors = validateSpec(text, commands, EXISTS).join("\n");
85
+ expect(errors).toContain("cargo test --test system");
86
+ expect(errors).toContain("check style");
87
+ });
88
+
89
+ test("the rubric lists conditional suites without guessing their applicability from prose", () => {
90
+ const commands = { ...COMMANDS, "verify.integration": [["unused"]], "build": [["unrelated"]] };
91
+ expect(validateSpec(VALID, commands, EXISTS)).toEqual(["rubric misses declared command verbatim: unused"]);
92
+ const text = VALID.replace(RUBRIC_ROW, `${RUBRIC_ROW}\n| S2 | Conditional check | \`unused\` | gate reports pass or no code | P0 | |`);
93
+ expect(validateSpec(text, commands, EXISTS)).toEqual([]);
94
+ });
95
+
96
+ test("documentation verification does not require an application test lane", () => {
97
+ expect(validateSpec(VALID, { conform: [["true"]], "verify.docs": [["true"]] }, EXISTS)).toEqual([]);
98
+ });
99
+
100
+ test("the Markdown parser handles escaped pipes inside command arguments", () => {
101
+ const commands = { conform: [["check", "a|b"]] };
102
+ const text = VALID.replace("`true`", "`check a\\|b`");
103
+ expect(validateSpec(text, commands, EXISTS)).toEqual([]);
104
+ });
105
+
106
+ test("oversize specs report their measured size against the cap", () => {
107
+ expect(validateSpec(`${VALID}\n${"line\n".repeat(320)}`, COMMANDS, EXISTS).join("\n")).toMatch(/spec has \d+ lines; cap 320/);
108
+ });
109
+ });
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+
4
+ import { auditSpec } from "../audits/spec-template";
5
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
6
+ import { cleanupTemporaryDirectories, git, modelFor, newSpecRepository, ROOT, SPEC_RELATIVE, specFixture } from "./gates_test_support";
7
+
8
+ const CONFIG_PATH = ".oracle/orly.json";
9
+ const STAGED = "--staged";
10
+ const FILE = "--file";
11
+ const RESIDUE = "path/to/file.ext";
12
+ const GOOD = specFixture();
13
+ const VALID_CONFIG = { commands: { conform: [["true"]], "verify.unit": [["true"]] } };
14
+
15
+ afterEach(cleanupTemporaryDirectories);
16
+
17
+ function run(project: string, ...args: string[]) {
18
+ const result = Bun.spawnSync(["bash", join(ROOT, "audits/spec-template.sh"), ...args], {
19
+ cwd: project, env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe",
20
+ });
21
+ return { code: result.exitCode, output: `${result.stdout}${result.stderr}` };
22
+ }
23
+
24
+ describe("spec audit through real Git and file boundaries", () => {
25
+ test("an invalid index fails even when its working copy was repaired", async () => {
26
+ const project = newSpecRepository();
27
+ await modelFor(project);
28
+ await Bun.write(join(project, SPEC_RELATIVE), `${GOOD}\n${RESIDUE}\n`);
29
+ git(project, "add", SPEC_RELATIVE);
30
+ await Bun.write(join(project, SPEC_RELATIVE), GOOD);
31
+ const result = run(project, STAGED);
32
+ expect(result.code).toBe(1);
33
+ expect(result.output).toContain("unfilled template placeholder");
34
+ });
35
+
36
+ test("a repaired index passes despite invalid unstaged content and config", async () => {
37
+ const project = newSpecRepository();
38
+ await modelFor(project);
39
+ await Bun.write(join(project, SPEC_RELATIVE), `${GOOD}\nReady.\n`);
40
+ git(project, "add", SPEC_RELATIVE);
41
+ await Bun.write(join(project, SPEC_RELATIVE), RESIDUE);
42
+ await Bun.write(join(project, CONFIG_PATH), "invalid json");
43
+ const result = run(project, STAGED);
44
+ expect(result.code).toBe(0);
45
+ expect(result.output).toContain("sections, references, test mappings");
46
+ });
47
+
48
+ test("read-first references are resolved from the index", async () => {
49
+ const project = newSpecRepository();
50
+ await modelFor(project);
51
+ git(project, "rm", "README.md");
52
+ await Bun.write(join(project, "README.md"), "unstaged replacement");
53
+ const errors = await auditSpec([SPEC_RELATIVE, SPEC_RELATIVE, STAGED], project);
54
+ expect(errors.join("\n")).toContain("pointer does not resolve: README.md");
55
+ });
56
+
57
+ test("file mode checks working content and configuration", async () => {
58
+ const project = newSpecRepository();
59
+ await modelFor(project);
60
+ expect(await auditSpec([SPEC_RELATIVE, SPEC_RELATIVE, FILE], project)).toEqual([]);
61
+ await Bun.write(join(project, CONFIG_PATH), JSON.stringify({ commands: { "verify.custom": [["custom", "check"]] } }));
62
+ expect((await auditSpec([SPEC_RELATIVE, SPEC_RELATIVE, FILE], project)).join("\n")).toContain("custom check");
63
+ });
64
+
65
+ test.each([
66
+ ["invalid JSON", "{"],
67
+ ["missing commands", "{}"],
68
+ ["empty commands", '{"commands":{}}'],
69
+ ["empty group", '{"commands":{"conform":[]}}'],
70
+ ["invalid invocation", '{"commands":{"conform":[[]]}}'],
71
+ ["nonstring argument", '{"commands":{"conform":[[3]]}}'],
72
+ ["nonarray group", '{"commands":{"conform":true}}'],
73
+ ])("refuses %s without skipping parity", async (_label, config) => {
74
+ const project = newSpecRepository();
75
+ await Bun.write(join(project, CONFIG_PATH), config);
76
+ await expect(auditSpec([SPEC_RELATIVE, SPEC_RELATIVE, FILE], project)).rejects.toThrow();
77
+ expect(run(project, FILE, SPEC_RELATIVE).code).toBe(1);
78
+ });
79
+
80
+ test("missing config, staged config, and arguments fail explicitly", async () => {
81
+ const project = newSpecRepository();
82
+ await expect(auditSpec([], project)).rejects.toThrow("expected spec path");
83
+ await expect(auditSpec([SPEC_RELATIVE, SPEC_RELATIVE, FILE], project)).rejects.toThrow();
84
+ await Bun.write(join(project, CONFIG_PATH), JSON.stringify(VALID_CONFIG));
85
+ await expect(auditSpec([SPEC_RELATIVE, SPEC_RELATIVE, STAGED], project)).rejects.toThrow("from the index");
86
+ });
87
+
88
+ test("bulk scans retain their prohibited-pattern scope", async () => {
89
+ const project = newSpecRepository();
90
+ await Bun.write(join(project, SPEC_RELATIVE), "# historical incomplete spec\n");
91
+ expect(run(project, "--all").code).toBe(0);
92
+ await Bun.write(join(project, SPEC_RELATIVE), "# Spec\n## Estimated effort\n");
93
+ const result = run(project, "--all");
94
+ expect(result.code).toBe(1);
95
+ expect(result.output).toContain("Estimated effort");
96
+ });
97
+ });