@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/AGENTS.md +5 -5
- package/README.md +26 -8
- package/audits/agents-md.md +7 -3
- package/audits/spec-template.sh +30 -113
- package/audits/spec-template.ts +193 -0
- package/core/operating-model.md +5 -5
- 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 +93 -76
- 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/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
|
+
});
|
|
@@ -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
|
+
});
|