@agentsfleet/orly 0.5.0
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/.githooks/pre-commit +42 -0
- package/.githooks/pre-push +67 -0
- package/AGENTS.md +348 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SOUL.md +90 -0
- package/audits/agents-md.md +497 -0
- package/audits/agents-md.sh +353 -0
- package/audits/cross-tier-rates.sh +105 -0
- package/audits/data.sh +185 -0
- package/audits/deinit-pairs.sh +224 -0
- package/audits/design-tokens.sh +143 -0
- package/audits/doc-read.sh +183 -0
- package/audits/error-codes.sh +221 -0
- package/audits/fixtures/clean.diff +15 -0
- package/audits/fixtures/dirty.diff +16 -0
- package/audits/lifecycle-anchors.sh +22 -0
- package/audits/logging.sh +217 -0
- package/audits/msid-ui.sh +144 -0
- package/audits/parity-dispatch.sh +51 -0
- package/audits/rule-ledger-lib.sh +264 -0
- package/audits/rule-ledger.sh +225 -0
- package/audits/rule-paths.sh +108 -0
- package/audits/spec-template.sh +258 -0
- package/audits/sql-mod.sh +102 -0
- package/audits/ufs.sh +303 -0
- package/bin/orly +26 -0
- package/core/operating-model.md +256 -0
- package/dispatch/edit_rules.md +69 -0
- package/dispatch/lib.sh +213 -0
- package/dispatch/lifecycle.md +187 -0
- package/dispatch/name_architecture.md +93 -0
- package/dispatch/verify.md +111 -0
- package/dispatch/write_any.md +740 -0
- package/dispatch/write_any.sh +49 -0
- package/dispatch/write_auth.md +36 -0
- package/dispatch/write_changelog.md +64 -0
- package/dispatch/write_documentation.md +45 -0
- package/dispatch/write_go.md +24 -0
- package/dispatch/write_http.md +45 -0
- package/dispatch/write_mdx.md +7 -0
- package/dispatch/write_python.md +8 -0
- package/dispatch/write_rust.md +38 -0
- package/dispatch/write_shell.md +8 -0
- package/dispatch/write_spec.md +105 -0
- package/dispatch/write_sql.md +112 -0
- package/dispatch/write_sql.sh +36 -0
- package/dispatch/write_ts_adhere_bun.md +493 -0
- package/dispatch/write_ts_adhere_bun.sh +37 -0
- package/dispatch/write_zig.md +852 -0
- package/dispatch/write_zig.sh +40 -0
- package/docs/CHANGELOG_VOICE.md +18 -0
- package/docs/DISPATCH_ARCHITECTURE.md +664 -0
- package/docs/DISPATCH_REVIEW_DISPOSITION.md +65 -0
- package/docs/DOCUMENTATION_RULES.md +209 -0
- package/docs/EXECUTE_DOC_READS.md +29 -0
- package/docs/HARNESS_VERIFY_OUTPUT.md +40 -0
- package/docs/LIFECYCLE_PATTERNS.md +316 -0
- package/docs/LOGGING_STANDARD.md +397 -0
- package/docs/ORLY_ARCHITECTURE.md +129 -0
- package/docs/REST_API_DESIGN_GUIDELINES.md +783 -0
- package/docs/RULE_ENFORCEMENT.md +41 -0
- package/docs/SCHEMA_CONVENTIONS.md +93 -0
- package/docs/TEMPLATE.md +411 -0
- package/docs/VERIFY_TIERS.md +57 -0
- package/docs/greptile-learnings/README.md +51 -0
- package/docs/greptile-learnings/RULES.md +1045 -0
- package/fixtures/registry-invalid.json +1 -0
- package/fixtures/registry-valid.json +18 -0
- package/fixtures/unclosed-pack-block.md +8 -0
- package/package.json +63 -0
- package/packs/language/go/rules.md +24 -0
- package/packs/language/mdx/rules.md +7 -0
- package/packs/language/python/rules.md +8 -0
- package/packs/language/rust/rules.md +38 -0
- package/packs/language/shell/rules.md +8 -0
- package/registry.json +405 -0
- package/schemas/evidence.schema.json +43 -0
- package/schemas/profile.schema.json +12 -0
- package/schemas/registry.schema.json +12 -0
- package/skills/orly-babysit-prs/SKILL.md +326 -0
- package/skills/orly-spec-new/SKILL.md +157 -0
- package/skills/orly-write-integration-test/SKILL.md +470 -0
- package/skills/orly-write-unit-test/SKILL.md +520 -0
- package/src/cli.test.ts +70 -0
- package/src/cli.ts +287 -0
- package/src/config.test.ts +226 -0
- package/src/config.ts +288 -0
- package/src/criteria.test.ts +137 -0
- package/src/criteria.ts +166 -0
- package/src/criteria_spec.ts +127 -0
- package/src/criteria_support.ts +53 -0
- package/src/gates.test.ts +183 -0
- package/src/gates.ts +169 -0
- package/src/gates_spec.test.ts +98 -0
- package/src/gates_test_support.ts +194 -0
- package/src/git_env.ts +20 -0
- package/src/install.test.ts +514 -0
- package/src/install.ts +447 -0
- package/src/install_nested.test.ts +32 -0
- package/src/model.test.ts +31 -0
- package/src/model.ts +245 -0
- package/src/references.test.ts +47 -0
- package/src/references.ts +147 -0
- package/src/render.test.ts +60 -0
- package/src/render.ts +94 -0
- package/src/surfaces.test.ts +43 -0
- package/src/surfaces.ts +89 -0
- package/src/validation.test.ts +11 -0
- package/src/validation.ts +87 -0
- package/src/verify.test.ts +13 -0
- package/src/verify.ts +59 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { CONFIG_PATH, localSelection, managedDrift, readConfig, RepoConfig, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
|
|
6
|
+
import { GateReport, isGateName, recordOverride, runGate, runGates } from "./gates";
|
|
7
|
+
import { install, InstallResult } from "./install";
|
|
8
|
+
import { isString, OrlyError, readJsonObject, RulesModel } from "./model";
|
|
9
|
+
import { Renderer } from "./render";
|
|
10
|
+
import { verifyRenders, writeEvidence } from "./verify";
|
|
11
|
+
|
|
12
|
+
const PASS_RESULT = "pass";
|
|
13
|
+
const NOT_REQUIRED_RESULT = "not-required";
|
|
14
|
+
const ACCEPT_DIRTY_FLAG = "--accept-dirty";
|
|
15
|
+
const REASON_FLAG = "--reason";
|
|
16
|
+
const PIPE_OUTPUT = "pipe";
|
|
17
|
+
const PACKAGE_MANIFEST = "package.json";
|
|
18
|
+
const FORCE_FLAG = "--force";
|
|
19
|
+
const NO_HOOKS_FLAG = "--no-hooks";
|
|
20
|
+
const WITH_FLAG = "--with";
|
|
21
|
+
const DRY_RUN_FLAG = "--dry-run";
|
|
22
|
+
const JSON_FLAG = "--json";
|
|
23
|
+
const JSON_INDENT = 2;
|
|
24
|
+
const PASS_GLYPH = "🟢";
|
|
25
|
+
const FAIL_GLYPH = "🔴";
|
|
26
|
+
const PR_GATE = "pr";
|
|
27
|
+
const ENGINE_MARKER = "evals/install/run.sh";
|
|
28
|
+
const VERIFY_COMMAND = "verify";
|
|
29
|
+
|
|
30
|
+
const { root, arguments: commandArguments } = parseRoot(Bun.argv.slice(2));
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const model = await RulesModel.load(root);
|
|
34
|
+
const exitCode = await run(model, commandArguments);
|
|
35
|
+
process.exit(exitCode);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
console.error(`orly: ${message}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function run(model: RulesModel, args: string[]): Promise<number> {
|
|
43
|
+
const [command, ...rest] = args;
|
|
44
|
+
if (!command || command === "--help" || command === "-h") {
|
|
45
|
+
printHelp();
|
|
46
|
+
return command ? 0 : 1;
|
|
47
|
+
}
|
|
48
|
+
if (command === "--version" || command === "-v") return printVersion(model);
|
|
49
|
+
if (command === "doctor") {
|
|
50
|
+
model.validate();
|
|
51
|
+
requireNoArguments(rest, "doctor checks the installed ruleset: orly doctor");
|
|
52
|
+
return doctorGlobal(model);
|
|
53
|
+
}
|
|
54
|
+
if (command === "init") return materialise(model, rest, true);
|
|
55
|
+
if (command === "update") return materialise(model, rest, false);
|
|
56
|
+
if (command === VERIFY_COMMAND) return verify(model, rest);
|
|
57
|
+
if (command === "gate") return gate(model, rest);
|
|
58
|
+
if (command === "override") return override(rest);
|
|
59
|
+
throw new OrlyError(`unknown command: ${command}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Read-only: run every gate in order (stop at the first red group), or one
|
|
63
|
+
// named gate. Nothing is ever written.
|
|
64
|
+
function gate(model: RulesModel, args: string[]): number {
|
|
65
|
+
const acceptDirty = args.includes(ACCEPT_DIRTY_FLAG);
|
|
66
|
+
const named = args.find((argument) => !argument.startsWith("-"));
|
|
67
|
+
if (named !== undefined && !isGateName(named)) throw new OrlyError(`unknown gate: ${named} (work, verify, pr)`);
|
|
68
|
+
const reports = named ? [runGate(model, projectRoot(), named, acceptDirty)] : runGates(model, projectRoot(), acceptDirty);
|
|
69
|
+
for (const report of reports) printGate(report);
|
|
70
|
+
const allGreen = reports.every((report) => report.ok);
|
|
71
|
+
if (allGreen && (named === undefined || named === PR_GATE)) console.log(`${PASS_GLYPH} PR boundary open — CHORE(close) is the next motion`);
|
|
72
|
+
return allGreen ? 0 : 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function override(args: string[]): number {
|
|
76
|
+
const criterion = args.find((argument) => !argument.startsWith("-")) ?? "";
|
|
77
|
+
if (!criterion) throw new OrlyError("override requires one criterion name");
|
|
78
|
+
const reason = optionalValue(args, REASON_FLAG)?.trim();
|
|
79
|
+
if (!reason) throw new OrlyError(`${REASON_FLAG} is required and must not be empty — an override without a reason is not a record`);
|
|
80
|
+
recordOverride(projectRoot(), criterion, reason);
|
|
81
|
+
console.log(`🟡 recorded override of ${criterion} as an empty commit — it rides the branch into the PR`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The published manifest is the single version source — nothing in the sources
|
|
86
|
+
// carries a second copy that could disagree with what was installed.
|
|
87
|
+
async function printVersion(model: RulesModel): Promise<number> {
|
|
88
|
+
console.log(await packageVersion(model));
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function packageVersion(model: RulesModel): Promise<string> {
|
|
93
|
+
const manifest = await readJsonObject(join(model.root, PACKAGE_MANIFEST));
|
|
94
|
+
if (!isString(manifest.version)) throw new OrlyError(`${PACKAGE_MANIFEST} carries no version string`);
|
|
95
|
+
return manifest.version;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Both verbs materialise the same way: the repository's own `.oracle/orly.json`
|
|
99
|
+
// names its packs and commands, so neither asks the caller who this repo is.
|
|
100
|
+
async function materialise(model: RulesModel, args: string[], isInit: boolean): Promise<number> {
|
|
101
|
+
model.validate();
|
|
102
|
+
const targetRoot = projectRoot();
|
|
103
|
+
const existing = await readConfig(targetRoot);
|
|
104
|
+
if (!isInit && !existing) throw new OrlyError(`no ${CONFIG_PATH} here — run \`orly init\` first`);
|
|
105
|
+
const requested = optionalValues(args, WITH_FLAG);
|
|
106
|
+
// --dry-run changes nothing, and recording an opt-in pack is a change. It
|
|
107
|
+
// still previews as though the pack were taken, so what you see is what the
|
|
108
|
+
// real run would write.
|
|
109
|
+
if (args.includes(DRY_RUN_FLAG)) return preview(model, targetRoot, requested);
|
|
110
|
+
// Otherwise the pack is a property of the repository, recorded before the
|
|
111
|
+
// render rather than passed through it: the next `orly update` in a
|
|
112
|
+
// teammate's clone selects the same set with no flag to remember.
|
|
113
|
+
await recordOptIn(model, targetRoot, existing, requested);
|
|
114
|
+
const result = await install(model, {
|
|
115
|
+
targetRoot,
|
|
116
|
+
force: args.includes(FORCE_FLAG),
|
|
117
|
+
installHooks: !args.includes(NO_HOOKS_FLAG),
|
|
118
|
+
orlyVersion: await packageVersion(model),
|
|
119
|
+
});
|
|
120
|
+
if (args.includes(JSON_FLAG)) console.log(JSON.stringify(result, undefined, JSON_INDENT));
|
|
121
|
+
else printInstall(result);
|
|
122
|
+
return result.ok ? 0 : 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printInstall(result: InstallResult): void {
|
|
126
|
+
for (const error of result.errors) console.log(`${FAIL_GLYPH} ${error.path}: ${error.message} — ${error.suggestion}`);
|
|
127
|
+
if (!result.ok) return;
|
|
128
|
+
console.log(`${PASS_GLYPH} ${result.written.length} written, ${result.skipped.length} already current (${result.packs.length} packs)`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function printGate(report: GateReport): void {
|
|
132
|
+
console.log(`🔆 gate ${report.gate}`);
|
|
133
|
+
for (const result of report.results) console.log(` ${result.ok ? PASS_GLYPH : FAIL_GLYPH} ${result.name}: ${result.detail}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function projectRoot(): string {
|
|
137
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT });
|
|
138
|
+
if (result.exitCode !== 0) throw new OrlyError(`not inside a Git repository: ${process.cwd()} — run \`git init\` first`);
|
|
139
|
+
return result.stdout.toString().trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function doctorGlobal(model: RulesModel): Promise<number> {
|
|
143
|
+
// Every repository is the same case now: doctor reports on the ruleset
|
|
144
|
+
// installed where the caller is standing. There is no machine-level carrier
|
|
145
|
+
// to check — the rules ride in each repository's own commit.
|
|
146
|
+
const errors = await doctorInstall(model);
|
|
147
|
+
if (errors.length > 0) {
|
|
148
|
+
for (const error of errors) console.log(`${FAIL_GLYPH} ${error}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
const config = await readConfig(projectRoot());
|
|
152
|
+
if (!config) {
|
|
153
|
+
console.log(`${FAIL_GLYPH} no ${CONFIG_PATH} here — run \`orly init\` first`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
console.log(`${PASS_GLYPH} this repository's installed ruleset matches ${CONFIG_PATH}`);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A repository with no lock was never installed into — silence, not a finding.
|
|
161
|
+
// One that has a lock must still match it, or the rules it claims to enforce
|
|
162
|
+
// are not the rules on disk.
|
|
163
|
+
async function doctorInstall(model: RulesModel): Promise<string[]> {
|
|
164
|
+
const targetRoot = projectRoot();
|
|
165
|
+
const config = await readConfig(targetRoot);
|
|
166
|
+
if (!config) return [];
|
|
167
|
+
const stale = staleVersion(config, await packageVersion(model));
|
|
168
|
+
return [...managedDrift(targetRoot, config), ...(stale ? [stale] : [])];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// What init would write, without writing it. Replaces the `render` verb: the
|
|
172
|
+
// preview belongs on the command you are about to run, not beside it.
|
|
173
|
+
async function preview(model: RulesModel, targetRoot: string, requested: string[] = []): Promise<number> {
|
|
174
|
+
const local = await localSelection(model, targetRoot);
|
|
175
|
+
// selectPacks validates the requested names and unions them with what the
|
|
176
|
+
// repository's own sources select — the same call the real run makes, minus
|
|
177
|
+
// the write.
|
|
178
|
+
const packs = requested.length > 0 ? selectPacks(model, targetRoot, [...local.packs, ...requested]) : local.packs;
|
|
179
|
+
const text = await new Renderer(model).renderText(packs, local.commands);
|
|
180
|
+
console.log(text);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Opt-in packs never auto-select from file extensions, so naming one is the
|
|
185
|
+
// only way it lands. Validated against the registry here, before anything is
|
|
186
|
+
// recorded, so a typo names the available set instead of writing a config the
|
|
187
|
+
// next command rejects.
|
|
188
|
+
async function recordOptIn(model: RulesModel, targetRoot: string, existing: RepoConfig | undefined, requested: string[]): Promise<void> {
|
|
189
|
+
if (requested.length === 0) return;
|
|
190
|
+
const config = existing ?? await seedConfig(targetRoot);
|
|
191
|
+
const packs = new Set(config.packs);
|
|
192
|
+
for (const name of requested) packs.add(name);
|
|
193
|
+
// selectPacks throws OrlyError on an unknown name, listing what is available.
|
|
194
|
+
selectPacks(model, targetRoot, [...packs]);
|
|
195
|
+
await writeConfig(targetRoot, { ...config, packs: [...packs].sort() });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function verify(model: RulesModel, args: string[]): Promise<number> {
|
|
199
|
+
requireEngineCheckout(model, VERIFY_COMMAND);
|
|
200
|
+
const checks = await verifyRenders(model);
|
|
201
|
+
for (const check of checks) console.log(`${check.result === PASS_RESULT ? PASS_GLYPH : FAIL_GLYPH} ${check.name}${check.detail ? `: ${check.detail}` : ""}`);
|
|
202
|
+
if (args.includes("--write-evidence")) {
|
|
203
|
+
const result = optionalValue(args, "--llm-result") ?? NOT_REQUIRED_RESULT;
|
|
204
|
+
if (result !== PASS_RESULT && result !== NOT_REQUIRED_RESULT) throw new OrlyError("--llm-result must be pass or not-required");
|
|
205
|
+
console.log(`evidence: ${await writeEvidence(model, "dotfiles", checks, result)}`);
|
|
206
|
+
}
|
|
207
|
+
return checks.every((check) => check.result === PASS_RESULT) ? 0 : 1;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Ruleset-authoring verbs operate on the engine's own sources and, in sync's
|
|
211
|
+
// case, repoint this machine's agent homes at the render. From a published
|
|
212
|
+
// payload that render lives in a package cache, so the links would resolve into
|
|
213
|
+
// a directory the package manager may delete — the rules would vanish mid-
|
|
214
|
+
// session. The marker is a file the payload allowlist deliberately excludes and
|
|
215
|
+
// every checkout carries; `.git` would be wrong, since a CI job or an exported
|
|
216
|
+
// tree is still the engine and a package cache never is.
|
|
217
|
+
function requireEngineCheckout(model: RulesModel, command: string): void {
|
|
218
|
+
if (existsSync(join(model.root, ENGINE_MARKER))) return;
|
|
219
|
+
throw new OrlyError(`orly ${command} edits the ruleset itself and only runs in an orly checkout — from an installed package use \`orly init\`, \`orly update\`, \`orly doctor\`, or \`orly gate\``);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseRoot(args: string[]): { root: string; arguments: string[] } {
|
|
223
|
+
const index = args.indexOf("--root");
|
|
224
|
+
if (index < 0) return { root: resolve(import.meta.dir, ".."), arguments: args };
|
|
225
|
+
const value = args[index + 1];
|
|
226
|
+
if (!value) throw new OrlyError("--root requires a path");
|
|
227
|
+
return { root: resolve(value), arguments: args.filter((_, position) => position !== index && position !== index + 1) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function requireNoArguments(args: string[], usage: string): void {
|
|
231
|
+
if (args.length > 0) throw new OrlyError(usage);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function optionValue(args: string[], name: string): string {
|
|
235
|
+
const value = optionalValue(args, name);
|
|
236
|
+
if (!value) throw new OrlyError(`${name} is required`);
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function optionalValue(args: string[], name: string): string | undefined {
|
|
241
|
+
const index = args.indexOf(name);
|
|
242
|
+
return index < 0 ? undefined : args[index + 1];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Repeatable: `--with persona.indy --with product.agentsfleet` takes both.
|
|
246
|
+
function optionalValues(args: string[], name: string): string[] {
|
|
247
|
+
const values: string[] = [];
|
|
248
|
+
for (const [index, argument] of args.entries()) {
|
|
249
|
+
if (argument !== name) continue;
|
|
250
|
+
const value = args[index + 1];
|
|
251
|
+
if (value === undefined || value.startsWith("-")) throw new OrlyError(`${name} requires a pack name`);
|
|
252
|
+
values.push(value);
|
|
253
|
+
}
|
|
254
|
+
return values;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function printHelp(): void {
|
|
258
|
+
console.log(`orly — prove the boundary; carry the rules
|
|
259
|
+
|
|
260
|
+
Gates (read-only; no PR without every criterion green or a recorded override):
|
|
261
|
+
orly gate [--accept-dirty] run work → verify → pr; stop at first red
|
|
262
|
+
orly gate <work|verify|pr> run one gate
|
|
263
|
+
orly override <CRITERION> --reason <REASON>
|
|
264
|
+
empty commit with an Orly-Override trailer
|
|
265
|
+
|
|
266
|
+
Install (the repository is the unit — no checkout of this package required):
|
|
267
|
+
orly init [--force] [--no-hooks] [--with <PACK>] [--dry-run] [--json]
|
|
268
|
+
materialise rules, gates, hooks, and a
|
|
269
|
+
seeded .oracle/orly.json. A repository that
|
|
270
|
+
already has an AGENTS.md keeps it: orly's
|
|
271
|
+
rules land as AGENTS.orly.md, reached by a
|
|
272
|
+
pointer block in the file you own.
|
|
273
|
+
orly update [--force] [--with <PACK>] [--dry-run] [--json]
|
|
274
|
+
re-materialise at the installed engine version
|
|
275
|
+
|
|
276
|
+
--with <PACK> record an opt-in pack (repeatable) in
|
|
277
|
+
.oracle/orly.json, so every clone selects it
|
|
278
|
+
--dry-run show what would be written; change nothing
|
|
279
|
+
|
|
280
|
+
orly doctor check this repository's installed rules
|
|
281
|
+
against what orly would write today
|
|
282
|
+
orly --version the installed package version
|
|
283
|
+
|
|
284
|
+
Ruleset authoring (an orly checkout only — refused from an installed package):
|
|
285
|
+
orly verify the rules render the same twice, and the
|
|
286
|
+
committed copy matches that render`);
|
|
287
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { CONFIG_PATH, managedDrift, readConfig, readConfigSync, seedConfig, selectPacks, staleVersion, writeConfig } from "./config";
|
|
7
|
+
import { RulesModel } from "./model";
|
|
8
|
+
|
|
9
|
+
const ROOT = resolve(import.meta.dir, "..");
|
|
10
|
+
const RUST_SOURCE = "src/lib.rs";
|
|
11
|
+
const SHELL_PACK = "language.shell";
|
|
12
|
+
const RUST_PACK = "language.rust";
|
|
13
|
+
const ZIG_PACK = "language.zig";
|
|
14
|
+
|
|
15
|
+
function scratch(): string {
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), "orly-config-"));
|
|
17
|
+
temporary.push(root);
|
|
18
|
+
return root;
|
|
19
|
+
}
|
|
20
|
+
const temporary: string[] = [];
|
|
21
|
+
|
|
22
|
+
function writeConfigText(root: string, body: unknown): void {
|
|
23
|
+
mkdirSync(join(root, ".oracle"), { recursive: true });
|
|
24
|
+
writeFileSync(join(root, CONFIG_PATH), JSON.stringify(body));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("selectPacks", () => {
|
|
28
|
+
test("selects a language pack only when the repository carries that language", async () => {
|
|
29
|
+
const model = await RulesModel.load(ROOT);
|
|
30
|
+
const root = scratch();
|
|
31
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
32
|
+
writeFileSync(join(root, RUST_SOURCE), "pub fn main() {}\n");
|
|
33
|
+
|
|
34
|
+
const packs = selectPacks(model, root, []);
|
|
35
|
+
|
|
36
|
+
expect(packs).toContain(RUST_PACK);
|
|
37
|
+
expect(packs).not.toContain(ZIG_PACK);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("never infers an opt-in pack, and takes it when the config names it", async () => {
|
|
41
|
+
const model = await RulesModel.load(ROOT);
|
|
42
|
+
const root = scratch();
|
|
43
|
+
|
|
44
|
+
expect(selectPacks(model, root, [])).not.toContain("persona.indy");
|
|
45
|
+
expect(selectPacks(model, root, ["persona.indy"])).toContain("persona.indy");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an unknown pack in the config is named, not silently dropped", async () => {
|
|
49
|
+
const model = await RulesModel.load(ROOT);
|
|
50
|
+
|
|
51
|
+
expect(() => selectPacks(model, scratch(), ["language.cobol"])).toThrow("unknown pack");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Regression: install writes shell gate scripts. Counting them would select
|
|
55
|
+
// the shell pack on the next install, which writes more shell — a set that
|
|
56
|
+
// never settles. Files the lock records are excluded from the scan.
|
|
57
|
+
test("files orly materialised are excluded, so selection settles", async () => {
|
|
58
|
+
const model = await RulesModel.load(ROOT);
|
|
59
|
+
const root = scratch();
|
|
60
|
+
mkdirSync(join(root, "dispatch"), { recursive: true });
|
|
61
|
+
writeFileSync(join(root, "dispatch/write_any.sh"), "#!/usr/bin/env bash\n");
|
|
62
|
+
|
|
63
|
+
expect(selectPacks(model, root, [])).toContain(SHELL_PACK);
|
|
64
|
+
expect(selectPacks(model, root, [], new Set(["dispatch/write_any.sh"]))).not.toContain(SHELL_PACK);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("seedConfig", () => {
|
|
69
|
+
test("maps Makefile targets onto gate commands, following one include level", async () => {
|
|
70
|
+
const root = scratch();
|
|
71
|
+
mkdirSync(join(root, "make"), { recursive: true });
|
|
72
|
+
writeFileSync(join(root, "Makefile"), "include make/test.mk\nhelp:\n\t@echo help\n");
|
|
73
|
+
writeFileSync(join(root, "make/test.mk"), "harness-verify:\n\t@true\ntest-unit-all:\n\t@true\n");
|
|
74
|
+
|
|
75
|
+
const seed = await seedConfig(root);
|
|
76
|
+
|
|
77
|
+
expect(seed.commands.conform).toEqual([["make", "harness-verify"]]);
|
|
78
|
+
expect(seed.commands["verify.unit"]).toEqual([["make", "test-unit-all"]]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("maps package.json scripts when there is no Makefile", async () => {
|
|
82
|
+
const root = scratch();
|
|
83
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ scripts: { test: "vitest", build: "tsc" } }));
|
|
84
|
+
|
|
85
|
+
const seed = await seedConfig(root);
|
|
86
|
+
|
|
87
|
+
expect(seed.commands["verify.unit"]).toEqual([["bun", "run", "test"]]);
|
|
88
|
+
expect(seed.commands["verify.build"]).toEqual([["bun", "run", "build"]]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// A repository whose only quality target is `lint` matches both CONFORM and
|
|
92
|
+
// verify.lint. Emitting both makes the gate run one command twice, in two
|
|
93
|
+
// tiers, for one signal.
|
|
94
|
+
test("a command that satisfies conform is not repeated as a verify tier", async () => {
|
|
95
|
+
const root = scratch();
|
|
96
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ scripts: { lint: "eslint ." } }));
|
|
97
|
+
|
|
98
|
+
const seed = await seedConfig(root);
|
|
99
|
+
|
|
100
|
+
expect(seed.commands.conform).toEqual([["bun", "run", "lint"]]);
|
|
101
|
+
expect(seed.commands["verify.lint"]).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a Makefile target beats a package.json script of the same name", async () => {
|
|
105
|
+
const root = scratch();
|
|
106
|
+
writeFileSync(join(root, "Makefile"), "test:\n\t@true\n");
|
|
107
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ scripts: { test: "vitest" } }));
|
|
108
|
+
|
|
109
|
+
expect((await seedConfig(root)).commands["verify.unit"]).toEqual([["make", "test"]]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a repository with no build files seeds no commands rather than wrong ones", async () => {
|
|
113
|
+
expect((await seedConfig(scratch())).commands).toEqual({});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("readConfig", () => {
|
|
118
|
+
test("a malformed command is rejected by name", async () => {
|
|
119
|
+
const root = scratch();
|
|
120
|
+
writeConfigText(root, { schema_version: 1, packs: [], commands: { conform: "make audit" } });
|
|
121
|
+
|
|
122
|
+
expect(readConfig(root)).rejects.toThrow("conform");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("an unknown surface field is rejected", () => {
|
|
126
|
+
const root = scratch();
|
|
127
|
+
writeConfigText(root, { schema_version: 1, packs: [], commands: {}, surfaces: { sideways: ["src/"] } });
|
|
128
|
+
|
|
129
|
+
expect(() => readConfigSync(root)).toThrow("sideways");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the sync and async readers agree", async () => {
|
|
133
|
+
const root = scratch();
|
|
134
|
+
writeConfigText(root, { schema_version: 1, packs: [], commands: { conform: [["make", "audit"]] }, surfaces: { user: ["src/"] } });
|
|
135
|
+
|
|
136
|
+
expect(await readConfig(root)).toEqual(readConfigSync(root)!);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an absent config is undefined, not an error — the normal pre-init state", async () => {
|
|
140
|
+
expect(await readConfig(scratch())).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("staleVersion", () => {
|
|
145
|
+
test("a config installed by the running engine reports no staleness", async () => {
|
|
146
|
+
const config = { ...(await seedConfig(scratch())), orly_version: "0.4.0" };
|
|
147
|
+
expect(staleVersion(config, "0.4.0")).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("an older install names both versions and the fix command", async () => {
|
|
151
|
+
const config = { ...(await seedConfig(scratch())), orly_version: "0.3.0" };
|
|
152
|
+
const message = staleVersion(config, "0.4.0");
|
|
153
|
+
expect(message).toContain("0.3.0");
|
|
154
|
+
expect(message).toContain("0.4.0");
|
|
155
|
+
expect(message).toContain("orly update");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("managedDrift", () => {
|
|
160
|
+
test("every managed file present reports no drift", async () => {
|
|
161
|
+
const root = scratch();
|
|
162
|
+
writeFileSync(join(root, "AGENTS.md"), "rules\n");
|
|
163
|
+
const config = { ...(await seedConfig(root)), managed: ["AGENTS.md"] };
|
|
164
|
+
expect(managedDrift(root, config)).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Edits are deliberately NOT drift: the tree is a git repository, so a hand
|
|
168
|
+
// edit and `orly update` replacing it both show in `git diff` first.
|
|
169
|
+
test("an edited managed file is not drift — git already shows it", async () => {
|
|
170
|
+
const root = scratch();
|
|
171
|
+
writeFileSync(join(root, "AGENTS.md"), "edited by hand\n");
|
|
172
|
+
const config = { ...(await seedConfig(root)), managed: ["AGENTS.md"] };
|
|
173
|
+
expect(managedDrift(root, config)).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a deleted managed file is reported, sorted, with a recovery command", async () => {
|
|
177
|
+
const root = scratch();
|
|
178
|
+
const config = { ...(await seedConfig(root)), managed: ["zzz.md", "aaa.md"] };
|
|
179
|
+
const findings = managedDrift(root, config);
|
|
180
|
+
expect(findings).toHaveLength(2);
|
|
181
|
+
expect(findings[0]).toContain("aaa.md");
|
|
182
|
+
expect(findings[1]).toContain("zzz.md");
|
|
183
|
+
expect(findings[0]).toContain("orly update");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("writeConfig", () => {
|
|
188
|
+
// The whole point of folding the lock in: orly rewrites this file on every
|
|
189
|
+
// install, so the repository's own three fields must survive byte-for-byte.
|
|
190
|
+
test("a rewrite preserves the repository's fields and updates only orly's", async () => {
|
|
191
|
+
const root = scratch();
|
|
192
|
+
const mine = {
|
|
193
|
+
schema_version: 1,
|
|
194
|
+
orly_version: "0.3.0",
|
|
195
|
+
packs: ["persona.indy"],
|
|
196
|
+
commands: { conform: [["make", "audit"]] },
|
|
197
|
+
surfaces: { user: ["src/"], docs: ["docs/"] },
|
|
198
|
+
managed: ["AGENTS.md"],
|
|
199
|
+
};
|
|
200
|
+
await writeConfig(root, mine);
|
|
201
|
+
await writeConfig(root, { ...(await readConfig(root))!, orly_version: "0.4.0", managed: ["AGENTS.md", "dispatch/write_rust.md"] });
|
|
202
|
+
|
|
203
|
+
const after = await readConfig(root);
|
|
204
|
+
expect(after?.packs).toEqual(["persona.indy"]);
|
|
205
|
+
expect(after?.commands).toEqual({ conform: [["make", "audit"]] });
|
|
206
|
+
expect(after?.surfaces).toEqual({ user: ["src/"], docs: ["docs/"] });
|
|
207
|
+
expect(after?.orly_version).toBe("0.4.0");
|
|
208
|
+
expect(after?.managed).toEqual(["AGENTS.md", "dispatch/write_rust.md"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("managed paths are sorted, so a rewrite diffs cleanly", async () => {
|
|
212
|
+
const root = scratch();
|
|
213
|
+
await writeConfig(root, { ...(await seedConfig(root)), managed: ["zzz.md", "aaa.md"] });
|
|
214
|
+
expect((await readConfig(root))?.managed).toEqual(["aaa.md", "zzz.md"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("writeConfig creates the .oracle/ parent directory", async () => {
|
|
218
|
+
const root = scratch();
|
|
219
|
+
await writeConfig(root, await seedConfig(root));
|
|
220
|
+
expect(existsSync(join(root, CONFIG_PATH))).toBeTrue();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
process.on("exit", () => {
|
|
225
|
+
for (const path of temporary) rmSync(path, { recursive: true, force: true });
|
|
226
|
+
});
|