@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.
- package/AGENTS.md +6 -6
- package/README.md +26 -8
- package/audits/agents-md.md +7 -3
- package/audits/doc-read.sh +97 -16
- package/audits/spec-template.sh +30 -113
- package/audits/spec-template.ts +193 -0
- package/core/operating-model.md +6 -6
- package/dispatch/lifecycle.md +98 -42
- package/dispatch/verify.md +30 -48
- package/dispatch/write_spec.md +56 -82
- package/docs/ORLY_ARCHITECTURE.md +13 -3
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/docs/TEMPLATE.md +82 -77
- package/docs/VERIFY_TIERS.md +20 -26
- package/llms.txt +17 -12
- package/package.json +1 -1
- package/registry.json +4 -0
- package/skills/orly-spec-new/SKILL.md +3 -1
- package/skills/orly-write-integration-test/SKILL.md +1 -1
- package/skills/orly-write-unit-test/SKILL.md +1 -1
- package/src/cli.ts +14 -16
- package/src/criteria.test.ts +47 -6
- package/src/criteria.ts +30 -49
- package/src/criteria_spec.ts +53 -6
- package/src/criteria_support.ts +5 -4
- package/src/gates.test.ts +2 -2
- package/src/gates_spec.test.ts +40 -1
- package/src/gates_test_support.ts +18 -4
- package/src/install.ts +16 -81
- package/src/render.test.ts +5 -7
- package/src/setup.test.ts +116 -0
- package/src/spec_baseline.test.ts +96 -0
- package/src/spec_template.test.ts +109 -0
- package/src/spec_template_io.test.ts +97 -0
- package/src/validation.ts +9 -0
package/src/gates_spec.test.ts
CHANGED
|
@@ -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, "
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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|
|
|
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
|
-
//
|
|
48
|
-
//
|
|
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
|
|
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
|
|
165
|
-
return
|
|
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
|
-
//
|
|
171
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
package/src/render.test.ts
CHANGED
|
@@ -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
|
|
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("
|
|
73
|
-
expect(text).toContain("
|
|
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
|
-
|
|
81
|
-
|
|
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
|
+
});
|