@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/config.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { assertWritableInside, isObject, isString, JsonObject, objectValue, OrlyError, readJsonObject, RulesModel, stringArray } from "./model";
|
|
5
|
+
import { validateCommands, validateSurfaces } from "./validation";
|
|
6
|
+
|
|
7
|
+
const ORACLE_DIRECTORY = ".oracle";
|
|
8
|
+
export const CONFIG_PATH = `${ORACLE_DIRECTORY}/orly.json`;
|
|
9
|
+
|
|
10
|
+
const CONFIG_SCHEMA_VERSION = 1;
|
|
11
|
+
const JSON_INDENT = 2;
|
|
12
|
+
const NEWLINE = "\n";
|
|
13
|
+
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
14
|
+
const EXTENSIONS_FIELD = "extensions";
|
|
15
|
+
const PACKS_FIELD = "packs";
|
|
16
|
+
const VERSION_FIELD = "orly_version";
|
|
17
|
+
const MANAGED_FIELD = "managed";
|
|
18
|
+
const COMMANDS_FIELD = "commands";
|
|
19
|
+
const CONFORM_COMMAND = "conform";
|
|
20
|
+
const MAKE_COMMAND = "make";
|
|
21
|
+
const BUN_COMMAND = "bun";
|
|
22
|
+
const RUN_SUBCOMMAND = "run";
|
|
23
|
+
const MAKEFILE = "Makefile";
|
|
24
|
+
const PACKAGE_MANIFEST = "package.json";
|
|
25
|
+
const SCRIPTS_FIELD = "scripts";
|
|
26
|
+
const LINT_ALL_TARGET = "lint-all";
|
|
27
|
+
const LINT_TARGET = "lint";
|
|
28
|
+
const BUILD_TARGET = "build";
|
|
29
|
+
const TEXT_ENCODING = "utf8";
|
|
30
|
+
const TARGET_PATTERN = /^([A-Za-z0-9][A-Za-z0-9_.-]*):/;
|
|
31
|
+
const INCLUDE_PATTERN = /^-?include\s+(\S+)/;
|
|
32
|
+
|
|
33
|
+
// Packs a repository only receives when it asks: Kishore's own address handles
|
|
34
|
+
// and agentsfleet's product surface mean nothing in a stranger's checkout, so
|
|
35
|
+
// they are never inferred — `.oracle/orly.json` names them or they stay out.
|
|
36
|
+
const OPT_IN_PACKS = ["persona.indy", "product.agentsfleet", "workflow.governance"];
|
|
37
|
+
|
|
38
|
+
// Directories that are never the repository's own source: scanning them makes
|
|
39
|
+
// a Rust crate look like it writes TypeScript because one dependency does.
|
|
40
|
+
const SKIPPED_DIRECTORIES = new Set([".git", ".oracle", "node_modules", "vendor", "third_party", "target", "dist", "build", ".zig-cache", "zig-out", ".venv", "__pycache__", ".next", ".turbo", ".cache", "coverage", "out"]);
|
|
41
|
+
const SCAN_DEPTH = 4;
|
|
42
|
+
|
|
43
|
+
// A Makefile target or package script orly knows how to map onto a gate
|
|
44
|
+
// command. The first match wins, so the more specific name is listed first.
|
|
45
|
+
const CONFORM_TARGETS = ["harness-verify", "conform", "audit", LINT_ALL_TARGET, LINT_TARGET];
|
|
46
|
+
const VERIFY_TARGETS: Array<[string, string[]]> = [
|
|
47
|
+
["verify.lint", [LINT_ALL_TARGET, LINT_TARGET]],
|
|
48
|
+
["verify.unit", ["test-unit-all", "test-unit", "test"]],
|
|
49
|
+
["verify.integration", ["test-integration"]],
|
|
50
|
+
["verify.memory", ["memleak"]],
|
|
51
|
+
["verify.version", ["check-version"]],
|
|
52
|
+
["verify.build", [BUILD_TARGET]],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// One file per repository. Two regions, one owner each: `packs`, `commands` and
|
|
56
|
+
// `surfaces` are the repository's answer and orly only ever reads them;
|
|
57
|
+
// `orly_version` and `managed` are orly's record of what it installed and what
|
|
58
|
+
// it wrote, rewritten on every install. A round-trip preserves the repository's
|
|
59
|
+
// values exactly — they are data, not formatting.
|
|
60
|
+
export type RepoConfig = {
|
|
61
|
+
schema_version: number;
|
|
62
|
+
orly_version: string;
|
|
63
|
+
packs: string[];
|
|
64
|
+
commands: Record<string, string[][]>;
|
|
65
|
+
surfaces: JsonObject | undefined;
|
|
66
|
+
managed: string[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function configPath(targetRoot: string): string {
|
|
70
|
+
return join(targetRoot, CONFIG_PATH);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The repository's own answer to "what are my commands, what extra packs do I
|
|
74
|
+
// take". Hand-owned after `orly init` seeds it: orly never rewrites it, so an
|
|
75
|
+
// edit here survives every later `orly update`.
|
|
76
|
+
export async function readConfig(targetRoot: string): Promise<RepoConfig | undefined> {
|
|
77
|
+
const path = configPath(targetRoot);
|
|
78
|
+
if (!existsSync(path)) return undefined;
|
|
79
|
+
const value = await readJsonObject(path);
|
|
80
|
+
if (value.schema_version !== CONFIG_SCHEMA_VERSION) throw new OrlyError(`${CONFIG_PATH} schema_version must equal ${CONFIG_SCHEMA_VERSION}`);
|
|
81
|
+
return parseConfig(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Gate criteria evaluate synchronously — they read exit codes and files, never
|
|
85
|
+
// await — so the config they resolve against is read the same way.
|
|
86
|
+
export function readConfigSync(targetRoot: string): RepoConfig | undefined {
|
|
87
|
+
const path = configPath(targetRoot);
|
|
88
|
+
if (!existsSync(path)) return undefined;
|
|
89
|
+
const value: unknown = JSON.parse(readFileSync(path, TEXT_ENCODING));
|
|
90
|
+
if (!isObject(value)) throw new OrlyError(`${CONFIG_PATH} must be a JSON object`);
|
|
91
|
+
return parseConfig(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// One shape check for both readers: the declared commands and surfaces are what
|
|
95
|
+
// `orly gate` runs and diffs against, so a malformed one fails here by name
|
|
96
|
+
// rather than as a confusing red criterion later.
|
|
97
|
+
function parseConfig(value: JsonObject): RepoConfig {
|
|
98
|
+
if (value.schema_version !== CONFIG_SCHEMA_VERSION) throw new OrlyError(`${CONFIG_PATH} schema_version must equal ${CONFIG_SCHEMA_VERSION}`);
|
|
99
|
+
const errors: string[] = [];
|
|
100
|
+
if (value[COMMANDS_FIELD] !== undefined) validateCommands(CONFIG_PATH, value[COMMANDS_FIELD], errors);
|
|
101
|
+
validateSurfaces(CONFIG_PATH, value.surfaces, errors);
|
|
102
|
+
if (errors.length > 0) throw new OrlyError(errors.join("\n"));
|
|
103
|
+
const version = value[VERSION_FIELD];
|
|
104
|
+
return {
|
|
105
|
+
schema_version: CONFIG_SCHEMA_VERSION,
|
|
106
|
+
orly_version: isString(version) ? version : "",
|
|
107
|
+
packs: stringArray(value[PACKS_FIELD] ?? [], `${CONFIG_PATH} ${PACKS_FIELD}`),
|
|
108
|
+
commands: readCommands(value[COMMANDS_FIELD]),
|
|
109
|
+
surfaces: isObject(value.surfaces) ? value.surfaces : undefined,
|
|
110
|
+
managed: stringArray(value[MANAGED_FIELD] ?? [], `${CONFIG_PATH} ${MANAGED_FIELD}`),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// What orly installed here, and whether the engine has moved since.
|
|
115
|
+
export function staleVersion(config: RepoConfig, installedVersion: string): string | undefined {
|
|
116
|
+
if (config.orly_version === installedVersion) return undefined;
|
|
117
|
+
return `ruleset was installed by orly ${config.orly_version || "an unrecorded version"}, installed engine is ${installedVersion} — run \`orly update\``;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A managed file that is gone. Edits are not drift: the tree is a git
|
|
121
|
+
// repository, so `git diff` shows a hand edit and shows `orly update` replacing
|
|
122
|
+
// it, both before either is committed.
|
|
123
|
+
export function managedDrift(targetRoot: string, config: RepoConfig): string[] {
|
|
124
|
+
return config.managed
|
|
125
|
+
.filter((relativePath) => !existsSync(join(targetRoot, relativePath)))
|
|
126
|
+
.map((relativePath) => `managed file is missing: ${relativePath} — run \`orly update\` to restore it`)
|
|
127
|
+
.sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Key order is fixed so a rewrite produces a reviewable diff: orly's own two
|
|
131
|
+
// fields sit at the ends, the repository's three in the middle, untouched.
|
|
132
|
+
export async function writeConfig(targetRoot: string, config: RepoConfig): Promise<string> {
|
|
133
|
+
assertWritableInside(targetRoot, CONFIG_PATH, "config");
|
|
134
|
+
const path = configPath(targetRoot);
|
|
135
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
136
|
+
const ordered = {
|
|
137
|
+
schema_version: config.schema_version,
|
|
138
|
+
orly_version: config.orly_version,
|
|
139
|
+
packs: config.packs,
|
|
140
|
+
commands: config.commands,
|
|
141
|
+
...(config.surfaces ? { surfaces: config.surfaces } : {}),
|
|
142
|
+
managed: [...config.managed].sort(),
|
|
143
|
+
};
|
|
144
|
+
await Bun.write(path, `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`);
|
|
145
|
+
return path;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Seeded once, on the install that finds no config. The commands are a guess
|
|
149
|
+
// from what the repository already builds with; an empty set is honest rather
|
|
150
|
+
// than wrong, and says so in the gate's own failure message.
|
|
151
|
+
export async function seedConfig(targetRoot: string): Promise<RepoConfig> {
|
|
152
|
+
return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// What this repository installed and runs with, read from its own `.oracle/`.
|
|
156
|
+
// Every caller that used to ask a central registry "which profile is this
|
|
157
|
+
// checkout" asks the checkout instead, so the answer travels with the clone.
|
|
158
|
+
export async function localSelection(model: RulesModel, targetRoot: string): Promise<{ packs: string[]; commands: Record<string, string[][]>; surfaces: JsonObject | undefined }> {
|
|
159
|
+
const config = await readConfig(targetRoot);
|
|
160
|
+
return { packs: selectPacks(model, targetRoot, config?.packs ?? [], new Set(config?.managed ?? [])), commands: config?.commands ?? {}, surfaces: config?.surfaces };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Which packs this repository takes: every always-on pack, the language packs
|
|
164
|
+
// whose extensions actually appear in its tree, plus whatever it opted into.
|
|
165
|
+
// No profile name, no central registry — the repository decides by its own
|
|
166
|
+
// contents, so a fresh clone installs correctly with nothing to look up.
|
|
167
|
+
export function selectPacks(model: RulesModel, targetRoot: string, requested: string[], managed: Set<string> = new Set()): string[] {
|
|
168
|
+
const packs = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
|
|
169
|
+
const present = scanExtensions(targetRoot, managed);
|
|
170
|
+
const selected = new Set<string>();
|
|
171
|
+
for (const [name, value] of Object.entries(packs)) {
|
|
172
|
+
if (OPT_IN_PACKS.includes(name)) continue;
|
|
173
|
+
const extensions = isObject(value) ? stringArray(value[EXTENSIONS_FIELD] ?? [], `pack ${name} ${EXTENSIONS_FIELD}`) : [];
|
|
174
|
+
if (extensions.length === 0 || extensions.some((extension) => present.has(extension))) selected.add(name);
|
|
175
|
+
}
|
|
176
|
+
for (const name of requested) {
|
|
177
|
+
if (!(name in packs)) throw new OrlyError(`${CONFIG_PATH} names an unknown pack: ${name} (available: ${Object.keys(packs).sort().join(", ")})`);
|
|
178
|
+
selected.add(name);
|
|
179
|
+
}
|
|
180
|
+
return [...selected].sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Every file extension the repository's OWN source uses. Bounded in depth and
|
|
184
|
+
// blind to dependency directories, so the walk stays cheap on a large tree.
|
|
185
|
+
// Files orly itself materialised are excluded: the gate scripts it writes are
|
|
186
|
+
// shell, so counting them would select the shell pack on the second install,
|
|
187
|
+
// which writes more shell, which selects more — a set that never settles.
|
|
188
|
+
function scanExtensions(targetRoot: string, managed: Set<string>, depth = SCAN_DEPTH): Set<string> {
|
|
189
|
+
const found = new Set<string>();
|
|
190
|
+
const walk = (directory: string, remaining: number): void => {
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (entry.isDirectory()) {
|
|
199
|
+
if (remaining > 0 && !SKIPPED_DIRECTORIES.has(entry.name)) walk(join(directory, entry.name), remaining - 1);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const relativePath = relative(targetRoot, join(directory, entry.name)).replaceAll("\\", "/");
|
|
203
|
+
if (managed.has(relativePath)) continue;
|
|
204
|
+
const extension = extname(entry.name);
|
|
205
|
+
if (extension.length > 0) found.add(extension);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(targetRoot, depth);
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function sniffCommands(targetRoot: string): Promise<Record<string, string[][]>> {
|
|
213
|
+
const make = await makeTargets(targetRoot);
|
|
214
|
+
const scripts = await packageScripts(targetRoot);
|
|
215
|
+
const commands: Record<string, string[][]> = {};
|
|
216
|
+
const conform = pick(CONFORM_TARGETS, make, scripts);
|
|
217
|
+
if (conform) commands[CONFORM_COMMAND] = [conform];
|
|
218
|
+
for (const [key, candidates] of VERIFY_TARGETS) {
|
|
219
|
+
const found = pick(candidates, make, scripts);
|
|
220
|
+
// A repository with only `lint` matches both conform and verify.lint. The
|
|
221
|
+
// gate would then run one command twice, in two tiers, for one signal.
|
|
222
|
+
if (found && !(conform && sameInvocation(found, conform))) commands[key] = [found];
|
|
223
|
+
}
|
|
224
|
+
return commands;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function sameInvocation(left: string[], right: string[]): boolean {
|
|
228
|
+
return left.length === right.length && left.every((argument, index) => argument === right[index]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function pick(candidates: string[], make: Set<string>, scripts: Set<string>): string[] | undefined {
|
|
232
|
+
for (const candidate of candidates) {
|
|
233
|
+
if (make.has(candidate)) return [MAKE_COMMAND, candidate];
|
|
234
|
+
if (scripts.has(candidate)) return [BUN_COMMAND, RUN_SUBCOMMAND, candidate];
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Target names as the Makefile declares them: a line starting at column zero,
|
|
240
|
+
// up to the first colon. `include` is followed one level deep — a modular
|
|
241
|
+
// Makefile keeps every real target in make/*.mk, so a root-only read finds
|
|
242
|
+
// nothing but `help`. Enough to know a target exists, which is all the seed
|
|
243
|
+
// needs; the agent completing the config reads the file properly.
|
|
244
|
+
async function makeTargets(targetRoot: string): Promise<Set<string>> {
|
|
245
|
+
const targets = new Set<string>();
|
|
246
|
+
const root = join(targetRoot, MAKEFILE);
|
|
247
|
+
if (!existsSync(root)) return targets;
|
|
248
|
+
const files = [root, ...(await includedMakefiles(targetRoot, root))];
|
|
249
|
+
for (const file of files) {
|
|
250
|
+
if (!existsSync(file)) continue;
|
|
251
|
+
for (const line of (await Bun.file(file).text()).split(NEWLINE)) {
|
|
252
|
+
const match = TARGET_PATTERN.exec(line);
|
|
253
|
+
if (match?.[1]) targets.add(match[1]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return targets;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function includedMakefiles(targetRoot: string, root: string): Promise<string[]> {
|
|
260
|
+
const included: string[] = [];
|
|
261
|
+
for (const line of (await Bun.file(root).text()).split(NEWLINE)) {
|
|
262
|
+
const match = INCLUDE_PATTERN.exec(line);
|
|
263
|
+
if (match?.[1]) included.push(join(targetRoot, match[1].trim()));
|
|
264
|
+
}
|
|
265
|
+
return included;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function packageScripts(targetRoot: string): Promise<Set<string>> {
|
|
269
|
+
const path = join(targetRoot, PACKAGE_MANIFEST);
|
|
270
|
+
if (!existsSync(path)) return new Set();
|
|
271
|
+
try {
|
|
272
|
+
const manifest = await readJsonObject(path);
|
|
273
|
+
const scripts = manifest[SCRIPTS_FIELD];
|
|
274
|
+
return isObject(scripts) ? new Set(Object.keys(scripts)) : new Set();
|
|
275
|
+
} catch {
|
|
276
|
+
return new Set();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readCommands(value: unknown): Record<string, string[][]> {
|
|
281
|
+
if (value === undefined) return {};
|
|
282
|
+
const commands: Record<string, string[][]> = {};
|
|
283
|
+
for (const [key, invocations] of Object.entries(objectValue(value, `${CONFIG_PATH} ${COMMANDS_FIELD}`))) {
|
|
284
|
+
if (!Array.isArray(invocations)) throw new OrlyError(`${CONFIG_PATH} ${COMMANDS_FIELD}.${key} must be an array of invocations`);
|
|
285
|
+
commands[key] = invocations.map((invocation) => stringArray(invocation, `${CONFIG_PATH} ${COMMANDS_FIELD}.${key}`));
|
|
286
|
+
}
|
|
287
|
+
return commands;
|
|
288
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { criteriaFor, runCommand } from "./criteria";
|
|
6
|
+
import { cleanupTemporaryDirectories, modelFor, newRepository, newSpecRepository } from "./gates_test_support";
|
|
7
|
+
|
|
8
|
+
const WORK = "work";
|
|
9
|
+
const VERIFY = "verify";
|
|
10
|
+
const PR = "pr";
|
|
11
|
+
const REPO_CONFIG = "repo.config";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanupTemporaryDirectories);
|
|
14
|
+
|
|
15
|
+
// criteriaFor is the composition point: gates.ts asks it what a named gate
|
|
16
|
+
// evaluates, so a criterion silently dropped from a group would be invisible
|
|
17
|
+
// to every other suite. These pin the membership of each group by name.
|
|
18
|
+
describe("criteriaFor", () => {
|
|
19
|
+
async function contextFor(root: string) {
|
|
20
|
+
return { root, model: await modelFor(root), acceptDirty: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function named(gate: string, context: Awaited<ReturnType<typeof contextFor>>): string[] {
|
|
24
|
+
return criteriaFor(gate, context).map((criterion) => criterion.name).sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("work evaluates the branch, the tree and the profile", async () => {
|
|
28
|
+
const project = newSpecRepository();
|
|
29
|
+
expect(named(WORK, await contextFor(project))).toEqual(["git.branch", "git.tree", REPO_CONFIG]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("verify pairs the spec dimensions with the fast command tier only", async () => {
|
|
33
|
+
const project = newSpecRepository();
|
|
34
|
+
const names = named(VERIFY, await contextFor(project));
|
|
35
|
+
|
|
36
|
+
expect(names).toContain("spec.dimensions");
|
|
37
|
+
expect(names).toContain("cmd.conform");
|
|
38
|
+
// The slow tier belongs to the pr gate; leaking it here would make every
|
|
39
|
+
// verify run pay for integration and memory suites.
|
|
40
|
+
expect(names).not.toContain("cmd.verify.integration");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("pr carries every closed-spec follow-through criterion", async () => {
|
|
44
|
+
const project = newSpecRepository();
|
|
45
|
+
const names = named(PR, await contextFor(project));
|
|
46
|
+
|
|
47
|
+
for (const required of ["spec.moved", "spec.baseline", "spec.ordering", "spec.deferrals", "docs.updated"]) {
|
|
48
|
+
expect(names).toContain(required);
|
|
49
|
+
}
|
|
50
|
+
// git.branch is a work-gate concern: by pr time the branch is established,
|
|
51
|
+
// and re-asserting it would make an already-merged branch un-gateable.
|
|
52
|
+
expect(names).not.toContain("git.branch");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an unknown gate name yields no criteria rather than throwing", async () => {
|
|
56
|
+
const project = newSpecRepository();
|
|
57
|
+
expect(criteriaFor("nonsense", await contextFor(project))).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("an unregistered repository still yields a red profile criterion on verify", async () => {
|
|
61
|
+
const stranger = newRepository();
|
|
62
|
+
const context = { root: stranger, model: await modelFor(newSpecRepository()), acceptDirty: false };
|
|
63
|
+
|
|
64
|
+
const results = criteriaFor(VERIFY, context).map((criterion) => criterion.evaluate(context));
|
|
65
|
+
const config = results.find((result) => result.name === REPO_CONFIG);
|
|
66
|
+
expect(config?.ok).toBeFalse();
|
|
67
|
+
expect(config?.detail).toContain("orly init");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Reproduces the pre-push hook inside a linked worktree: git exports GIT_DIR
|
|
72
|
+
// and friends to its hooks, and an inherited GIT_DIR pins every spawned git to
|
|
73
|
+
// the hook's repository. A linked worktree's .git is a file, so the pinned
|
|
74
|
+
// path is not even a directory and every criterion judged the wrong tree.
|
|
75
|
+
describe("git scope leaking from a hook environment", () => {
|
|
76
|
+
test("criteria judge the handed path even with GIT_DIR exported", async () => {
|
|
77
|
+
const project = newSpecRepository();
|
|
78
|
+
const model = await modelFor(project);
|
|
79
|
+
const context = { root: project, model, acceptDirty: false };
|
|
80
|
+
const clean = criteriaFor(WORK, context).map((c) => c.evaluate(context));
|
|
81
|
+
|
|
82
|
+
const saved = { ...process.env };
|
|
83
|
+
process.env.GIT_DIR = ".git";
|
|
84
|
+
process.env.GIT_INDEX_FILE = ".git/index";
|
|
85
|
+
try {
|
|
86
|
+
const polluted = criteriaFor(WORK, context).map((c) => c.evaluate(context));
|
|
87
|
+
expect(polluted.map((r) => `${r.name}:${r.ok}`)).toEqual(clean.map((r) => `${r.name}:${r.ok}`));
|
|
88
|
+
} finally {
|
|
89
|
+
delete process.env.GIT_DIR; delete process.env.GIT_INDEX_FILE;
|
|
90
|
+
Object.assign(process.env, saved);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("runCommand", () => {
|
|
96
|
+
test("a zero exit reports ok with the exit-0 detail", () => {
|
|
97
|
+
expect(runCommand(process.cwd(), ["true"])).toEqual({ ok: true, detail: "exit 0" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a non-zero exit carries the code and the last output line", () => {
|
|
101
|
+
const result = runCommand(process.cwd(), ["sh", "-c", "echo first; echo decisive >&2; exit 3"]);
|
|
102
|
+
|
|
103
|
+
expect(result.ok).toBeFalse();
|
|
104
|
+
expect(result.detail).toContain("exit 3");
|
|
105
|
+
expect(result.detail).toEndWith("decisive");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a silent failure still reports a detail rather than an empty string", () => {
|
|
109
|
+
const result = runCommand(process.cwd(), ["false"]);
|
|
110
|
+
|
|
111
|
+
expect(result.ok).toBeFalse();
|
|
112
|
+
expect(result.detail).toBe("exit 1: no output");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a missing binary is a failure, never a throw", () => {
|
|
116
|
+
expect(runCommand(process.cwd(), ["orly-no-such-binary"]).ok).toBeFalse();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("repo.config", () => {
|
|
121
|
+
test("a repository with a config resolves its declared commands", async () => {
|
|
122
|
+
const project = newSpecRepository();
|
|
123
|
+
const context = { root: project, model: await modelFor(project), acceptDirty: false };
|
|
124
|
+
const criterion = criteriaFor(WORK, context).find((entry) => entry.name === REPO_CONFIG);
|
|
125
|
+
expect(criterion?.evaluate(context).ok).toBeTrue();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a repository with no config names the install command", async () => {
|
|
129
|
+
const project = newSpecRepository();
|
|
130
|
+
const context = { root: project, model: await modelFor(project), acceptDirty: false };
|
|
131
|
+
rmSync(join(project, ".oracle/orly.json"));
|
|
132
|
+
const criterion = criteriaFor(WORK, context).find((entry) => entry.name === REPO_CONFIG);
|
|
133
|
+
const verdict = criterion?.evaluate(context);
|
|
134
|
+
expect(verdict?.ok).toBeFalse();
|
|
135
|
+
expect(verdict?.detail).toContain("orly init");
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/criteria.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Criterion, CriterionContext, CriterionResult, criterion, gitOutput, runCommand, Verdict,
|
|
6
|
+
} from "./criteria_support";
|
|
7
|
+
import {
|
|
8
|
+
openQuestions, productClarity, specBaseline, specDeferrals, specDimensions,
|
|
9
|
+
specGate, specMoved, specOrdering,
|
|
10
|
+
} from "./criteria_spec";
|
|
11
|
+
import { isObject, JsonObject, objectValue, OrlyError, RulesModel } from "./model";
|
|
12
|
+
import { readConfigSync, RepoConfig } from "./config";
|
|
13
|
+
import { classifyBranch, SurfaceReport } from "./surfaces";
|
|
14
|
+
|
|
15
|
+
export type { Criterion, CriterionContext, CriterionResult, Verdict };
|
|
16
|
+
export { runCommand };
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BRANCHES = ["master", "main"];
|
|
19
|
+
const CONFORM_COMMAND = "conform";
|
|
20
|
+
const VERIFY_PREFIX = "verify.";
|
|
21
|
+
const REPOSITORIES_LABEL = "repositories";
|
|
22
|
+
const UNINSTALLED = "no .oracle/orly.json here — run `orly init` first";
|
|
23
|
+
const REV_PARSE = "rev-parse";
|
|
24
|
+
const ABBREV_REF = "--abbrev-ref";
|
|
25
|
+
const WORKTREE_LIST = ["worktree", "list", "--porcelain"];
|
|
26
|
+
const WORKTREE_PREFIX = "worktree ";
|
|
27
|
+
|
|
28
|
+
const GIT_BRANCH = "git.branch";
|
|
29
|
+
const GIT_TREE = "git.tree";
|
|
30
|
+
const GIT_PUSHED = "git.pushed";
|
|
31
|
+
const REPO_CONFIG = "repo.config";
|
|
32
|
+
const DOCS_UPDATED = "docs.updated";
|
|
33
|
+
|
|
34
|
+
const FAST_TIER = "fast";
|
|
35
|
+
const SLOW_TIER = "slow";
|
|
36
|
+
// The slow tier is a fixed name set, not a prefix rule: lint and version
|
|
37
|
+
// checks are verify.* too, and demoting them to skip-on-prose would be wrong.
|
|
38
|
+
const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
|
|
39
|
+
|
|
40
|
+
// Every criterion is mechanical: it reads an exit code or a file, never a
|
|
41
|
+
// judgment. The anchor invariant promises the machine can PROVE the PR
|
|
42
|
+
// boundary, so anything unprovable stays prose and never lands here.
|
|
43
|
+
// work = is this branch workable · verify = does the work hold up ·
|
|
44
|
+
// pr = can this ship (whole-branch checks + the slow suites).
|
|
45
|
+
export function criteriaFor(gate: string, context: CriterionContext): Criterion[] {
|
|
46
|
+
if (gate === "work") return [gitBranch(), gitTree(), repositoryConfig()];
|
|
47
|
+
if (gate === "verify") return [specDimensions(), ...commandCriteria(context, FAST_TIER)];
|
|
48
|
+
if (gate === "pr") {
|
|
49
|
+
return [
|
|
50
|
+
gitTree(), gitPushed(), specGate(), openQuestions(), productClarity(), specDimensions(),
|
|
51
|
+
specMoved(), specBaseline(), specOrdering(), specDeferrals(),
|
|
52
|
+
docsUpdated(), ...commandCriteria(context, SLOW_TIER),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function gitBranch(): Criterion {
|
|
59
|
+
return criterion(GIT_BRANCH, (context) => {
|
|
60
|
+
const branch = gitOutput(context.root, [REV_PARSE, ABBREV_REF, "HEAD"]);
|
|
61
|
+
const ok = branch.length > 0 && !DEFAULT_BRANCHES.includes(branch);
|
|
62
|
+
return { ok, detail: ok ? `on ${branch}` : `refusing to work on the default branch: ${branch || "unknown"}` };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function gitTree(): Criterion {
|
|
67
|
+
return criterion(GIT_TREE, (context) => {
|
|
68
|
+
// The active spec is excluded while work is in flight: Dimensions get
|
|
69
|
+
// marked DONE as the agent goes, and the tree check must not block on that
|
|
70
|
+
// bookkeeping. Committing the spec stays a CHORE(close) obligation.
|
|
71
|
+
const dirty = gitOutput(context.root, ["status", "--porcelain=v1", "-uall"])
|
|
72
|
+
.split(/\r?\n/)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.filter((line) => !context.specPath || !line.includes(context.specPath));
|
|
75
|
+
if (dirty.length === 0) return { ok: true, detail: "clean (active spec excluded)" };
|
|
76
|
+
if (context.acceptDirty) return { ok: true, detail: `${dirty.length} dirty path(s) accepted` };
|
|
77
|
+
return { ok: false, detail: `${dirty.length} uncommitted path(s), first: ${dirty[0] ?? ""}` };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function gitPushed(): Criterion {
|
|
82
|
+
return criterion(GIT_PUSHED, (context) => {
|
|
83
|
+
const upstream = gitOutput(context.root, [REV_PARSE, ABBREV_REF, "--symbolic-full-name", "@{upstream}"]);
|
|
84
|
+
if (upstream.length === 0) return { ok: false, detail: "branch has no upstream; push it first" };
|
|
85
|
+
const ahead = gitOutput(context.root, ["rev-list", "--count", "@{upstream}..HEAD"]);
|
|
86
|
+
const ok = ahead === "0";
|
|
87
|
+
return { ok, detail: ok ? `in sync with ${upstream}` : `${ahead} commit(s) not pushed to ${upstream}` };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function repositoryConfig(): Criterion {
|
|
92
|
+
return criterion(REPO_CONFIG, (context) => {
|
|
93
|
+
try {
|
|
94
|
+
const config = readConfigSync(context.root);
|
|
95
|
+
if (!config) return { ok: false, detail: UNINSTALLED };
|
|
96
|
+
const commands = Object.keys(config.commands).length;
|
|
97
|
+
return { ok: commands > 0, detail: commands > 0 ? `${commands} command(s) declared` : "no commands declared in .oracle/orly.json — add conform and verify.* so the gate can run them" };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Model C: the repository's declared command surface. Orly owns policy and
|
|
105
|
+
// invokes these; the repository owns what they actually do. Fast tier =
|
|
106
|
+
// conform + verify.unit. Slow tier = every other verify.* (integration,
|
|
107
|
+
// memleak); those auto-pass with a printed skip when the branch carries no
|
|
108
|
+
// code, so a prose-only branch never pays for the slow suites.
|
|
109
|
+
function commandCriteria(context: CriterionContext, tier: string): Criterion[] {
|
|
110
|
+
const config = resolvedConfig(context);
|
|
111
|
+
if (!config) return tier === FAST_TIER ? [criterion(REPO_CONFIG, () => ({ ok: false, detail: UNINSTALLED }))] : [];
|
|
112
|
+
const commands = config.commands;
|
|
113
|
+
const selected = Object.keys(commands).filter((key) => tierOf(key) === tier).sort();
|
|
114
|
+
return selected.map((key) => criterion(`cmd.${key}`, (inner) => {
|
|
115
|
+
if (tier === SLOW_TIER && report(inner, config.surfaces).code.length === 0) {
|
|
116
|
+
return { ok: true, detail: "skipped — no code files on this branch" };
|
|
117
|
+
}
|
|
118
|
+
return runInvocations(inner.root, commands[key]);
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function tierOf(key: string): string {
|
|
123
|
+
if (SLOW_COMMANDS.includes(key)) return SLOW_TIER;
|
|
124
|
+
if (key === CONFORM_COMMAND || key.startsWith(VERIFY_PREFIX)) return FAST_TIER;
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function docsUpdated(): Criterion {
|
|
129
|
+
return criterion(DOCS_UPDATED, (context) => {
|
|
130
|
+
const config = resolvedConfig(context);
|
|
131
|
+
if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .oracle/orly.json" };
|
|
132
|
+
const surfaces = report(context, config.surfaces);
|
|
133
|
+
if (surfaces.userSurface.length === 0) return { ok: true, detail: "no user-surface files on this branch" };
|
|
134
|
+
if (surfaces.docs.length > 0) return { ok: true, detail: `${surfaces.userSurface.length} user-surface file(s), ${surfaces.docs.length} docs file(s) updated` };
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
detail: `${surfaces.userSurface.length} user-surface file(s) changed (first: ${surfaces.userSurface[0] ?? ""}) with no docs change — update the docs page, or record: orly override ${DOCS_UPDATED} --reason <REASON>`,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The branch is classified once per inspection and cached on the context.
|
|
143
|
+
function report(context: CriterionContext, surfaces: JsonObject | undefined): SurfaceReport {
|
|
144
|
+
context.surfaces ??= classifyBranch(context.root, surfaces);
|
|
145
|
+
return context.surfaces;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolvedConfig(context: CriterionContext): RepoConfig | undefined {
|
|
149
|
+
try {
|
|
150
|
+
return readConfigSync(context.root);
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runInvocations(root: string, invocations: unknown): Verdict {
|
|
157
|
+
if (!Array.isArray(invocations) || invocations.length === 0) return { ok: false, detail: "command group is empty" };
|
|
158
|
+
for (const invocation of invocations) {
|
|
159
|
+
if (!Array.isArray(invocation) || invocation.length === 0) return { ok: false, detail: "command invocation is empty" };
|
|
160
|
+
const argv = invocation.map((argument) => String(argument));
|
|
161
|
+
const result = runCommand(root, argv);
|
|
162
|
+
if (!result.ok) return { ok: false, detail: `${argv.join(" ")} -> ${result.detail}` };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, detail: `${invocations.length} invocation(s) exit 0` };
|
|
165
|
+
}
|
|
166
|
+
|