@agentsfleet/orly 0.10.14 → 0.11.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-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +65 -29
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +12 -5
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +153 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +37 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +9 -6
- package/src/gates_spec.test.ts +52 -6
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
package/src/config.ts
CHANGED
|
@@ -1,60 +1,27 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
2
|
-
import { dirname,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { assertWritableInside, hashContent, isObject, isString, JsonObject, objectValue, OrlyError, readJsonObject, RulesModel, stringArray } from "./model";
|
|
5
5
|
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
6
|
+
import { selectPacks, sniffCommands } from "./config_discovery";
|
|
7
|
+
import { readExecution, type ExecutionConfig } from "./execution/config";
|
|
6
8
|
import { validateCommands, validateSurfaces } from "./validation";
|
|
9
|
+
import { validateConfiguration } from "./config_validation";
|
|
10
|
+
import { commandLimitsSchema, type LaneLimits } from "./command_limits";
|
|
7
11
|
|
|
8
|
-
const ORACLE_DIRECTORY = ".
|
|
12
|
+
const ORACLE_DIRECTORY = ".orly";
|
|
9
13
|
export const CONFIG_PATH = `${ORACLE_DIRECTORY}/orly.json`;
|
|
10
14
|
|
|
11
15
|
const CONFIG_SCHEMA_VERSION = 1;
|
|
12
16
|
const JSON_INDENT = 2;
|
|
13
17
|
const NEWLINE = "\n";
|
|
14
|
-
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
15
|
-
const EXTENSIONS_FIELD = "extensions";
|
|
16
18
|
const PACKS_FIELD = "packs";
|
|
17
19
|
const VERSION_FIELD = "orly_version";
|
|
18
20
|
const MANAGED_FIELD = "managed";
|
|
19
21
|
const DIGESTS_FIELD = "digests";
|
|
20
22
|
const DIGEST_ALGORITHM = "sha256";
|
|
21
23
|
const COMMANDS_FIELD = "commands";
|
|
22
|
-
const CONFORM_COMMAND = "conform";
|
|
23
|
-
const MAKE_COMMAND = "make";
|
|
24
|
-
const BUN_COMMAND = "bun";
|
|
25
|
-
const RUN_SUBCOMMAND = "run";
|
|
26
|
-
const MAKEFILE = "Makefile";
|
|
27
|
-
const PACKAGE_MANIFEST = "package.json";
|
|
28
|
-
const SCRIPTS_FIELD = "scripts";
|
|
29
|
-
const LINT_ALL_TARGET = "lint-all";
|
|
30
|
-
const LINT_TARGET = "lint";
|
|
31
|
-
const BUILD_TARGET = "build";
|
|
32
24
|
const TEXT_ENCODING = "utf8";
|
|
33
|
-
const TARGET_PATTERN = /^([A-Za-z0-9][A-Za-z0-9_.-]*):/;
|
|
34
|
-
const INCLUDE_PATTERN = /^-?include\s+(\S+)/;
|
|
35
|
-
|
|
36
|
-
// Packs a repository only receives when it asks: Kishore's own address handles
|
|
37
|
-
// and agentsfleet's product surface mean nothing in a stranger's checkout, so
|
|
38
|
-
// they are never inferred — `.oracle/orly.json` names them or they stay out.
|
|
39
|
-
const OPT_IN_PACKS = ["persona.indy", "product.agentsfleet", "workflow.governance"];
|
|
40
|
-
|
|
41
|
-
// Directories that are never the repository's own source: scanning them makes
|
|
42
|
-
// a Rust crate look like it writes TypeScript because one dependency does.
|
|
43
|
-
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"]);
|
|
44
|
-
const SCAN_DEPTH = 4;
|
|
45
|
-
|
|
46
|
-
// A Makefile target or package script orly knows how to map onto a gate
|
|
47
|
-
// command. The first match wins, so the more specific name is listed first.
|
|
48
|
-
const CONFORM_TARGETS = ["harness-verify", "conform", "audit", LINT_ALL_TARGET, LINT_TARGET];
|
|
49
|
-
const VERIFY_TARGETS: Array<[string, string[]]> = [
|
|
50
|
-
["verify.lint", [LINT_ALL_TARGET, LINT_TARGET]],
|
|
51
|
-
["verify.unit", ["test-unit-all", "test-unit", "test"]],
|
|
52
|
-
["verify.integration", ["test-integration"]],
|
|
53
|
-
["verify.memory", ["memleak"]],
|
|
54
|
-
["verify.version", ["check-version"]],
|
|
55
|
-
["verify.build", [BUILD_TARGET]],
|
|
56
|
-
];
|
|
57
|
-
|
|
58
25
|
// One file per repository. Two regions, one owner each: `packs`, `commands` and
|
|
59
26
|
// `surfaces` are the repository's answer and orly only ever reads them;
|
|
60
27
|
// `orly_version` and `managed` are orly's record of what it installed and what
|
|
@@ -68,6 +35,8 @@ export type RepoConfig = {
|
|
|
68
35
|
surfaces: JsonObject | undefined;
|
|
69
36
|
managed: string[];
|
|
70
37
|
digests: Record<string, string>;
|
|
38
|
+
execution?: ExecutionConfig | undefined;
|
|
39
|
+
limits?: LaneLimits;
|
|
71
40
|
};
|
|
72
41
|
|
|
73
42
|
export function configPath(targetRoot: string): string {
|
|
@@ -98,8 +67,9 @@ export function readConfigSync(targetRoot: string): RepoConfig | undefined {
|
|
|
98
67
|
// One shape check for both readers: the declared commands and surfaces are what
|
|
99
68
|
// `orly gate` runs and diffs against, so a malformed one fails here by name
|
|
100
69
|
// rather than as a confusing red criterion later.
|
|
101
|
-
function parseConfig(value: JsonObject): RepoConfig {
|
|
70
|
+
export function parseConfig(value: JsonObject): RepoConfig {
|
|
102
71
|
if (value.schema_version !== CONFIG_SCHEMA_VERSION) throw new OrlyError(`${CONFIG_PATH} schema_version must equal ${CONFIG_SCHEMA_VERSION}`);
|
|
72
|
+
validateConfiguration(value);
|
|
103
73
|
const errors: string[] = [];
|
|
104
74
|
if (value[COMMANDS_FIELD] !== undefined) validateCommands(CONFIG_PATH, value[COMMANDS_FIELD], errors);
|
|
105
75
|
validateSurfaces(CONFIG_PATH, value.surfaces, errors);
|
|
@@ -113,6 +83,8 @@ function parseConfig(value: JsonObject): RepoConfig {
|
|
|
113
83
|
surfaces: isObject(value.surfaces) ? value.surfaces : undefined,
|
|
114
84
|
managed: stringArray(value[MANAGED_FIELD] ?? [], `${CONFIG_PATH} ${MANAGED_FIELD}`),
|
|
115
85
|
digests: readDigests(value[DIGESTS_FIELD]),
|
|
86
|
+
execution: readExecution(value.execution),
|
|
87
|
+
...(isObject(value.limits) ? { limits: Object.fromEntries(Object.entries(value.limits).map(([lane, limits]) => [lane, commandLimitsSchema.parse(limits)])) } : {}),
|
|
116
88
|
};
|
|
117
89
|
}
|
|
118
90
|
|
|
@@ -121,7 +93,8 @@ function parseConfig(value: JsonObject): RepoConfig {
|
|
|
121
93
|
// read as "no claim", which is what keeps an older checkout green until its
|
|
122
94
|
// next update rather than failing it for a record it never had.
|
|
123
95
|
function readDigests(value: unknown): Record<string, string> {
|
|
124
|
-
if (
|
|
96
|
+
if (value === undefined) return {};
|
|
97
|
+
if (!isObject(value)) throw new OrlyError(`${CONFIG_PATH} digests must be an object`);
|
|
125
98
|
const digests: Record<string, string> = {};
|
|
126
99
|
for (const [path, digest] of Object.entries(value)) if (isString(digest)) digests[path] = digest;
|
|
127
100
|
return digests;
|
|
@@ -144,7 +117,7 @@ export function contentDigest(bytes: Uint8Array): string {
|
|
|
144
117
|
// checkout installed on that version is not reported as wholly drifted before
|
|
145
118
|
// its next update — a format change is not an edit, and saying so would be the
|
|
146
119
|
// false alarm that teaches people to ignore the real ones.
|
|
147
|
-
function sameDigest(recorded: string, actual: string): boolean {
|
|
120
|
+
export function sameDigest(recorded: string, actual: string): boolean {
|
|
148
121
|
const bare = (digest: string) => digest.startsWith(`${DIGEST_ALGORITHM}:`) ? digest.slice(DIGEST_ALGORITHM.length + 1) : digest;
|
|
149
122
|
return bare(recorded) === bare(actual);
|
|
150
123
|
}
|
|
@@ -212,7 +185,7 @@ function editedManaged(targetRoot: string, config: RepoConfig, present: string[]
|
|
|
212
185
|
if (!recorded) return [];
|
|
213
186
|
const actual = contentDigest(readFileSync(join(targetRoot, relativePath)));
|
|
214
187
|
if (sameDigest(recorded, actual)) return [];
|
|
215
|
-
return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or \`orly update\` to discard it`];
|
|
188
|
+
return [`managed file was edited after orly wrote it: ${relativePath} — move the change into the pack source, or explicitly use \`orly update --force\` to discard it`];
|
|
216
189
|
});
|
|
217
190
|
}
|
|
218
191
|
|
|
@@ -222,17 +195,23 @@ export async function writeConfig(targetRoot: string, config: RepoConfig): Promi
|
|
|
222
195
|
assertWritableInside(targetRoot, CONFIG_PATH, "config");
|
|
223
196
|
const path = configPath(targetRoot);
|
|
224
197
|
mkdirSync(dirname(path), { recursive: true });
|
|
198
|
+
await Bun.write(path, serialiseConfig(config));
|
|
199
|
+
return path;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function serialiseConfig(config: RepoConfig): string {
|
|
225
203
|
const ordered = {
|
|
226
204
|
schema_version: config.schema_version,
|
|
227
205
|
orly_version: config.orly_version,
|
|
228
206
|
packs: config.packs,
|
|
229
207
|
commands: config.commands,
|
|
230
208
|
...(config.surfaces ? { surfaces: config.surfaces } : {}),
|
|
209
|
+
...(config.execution ? { execution: config.execution } : {}),
|
|
210
|
+
...(config.limits ? { limits: config.limits } : {}),
|
|
231
211
|
managed: [...config.managed].sort(),
|
|
232
212
|
digests: Object.fromEntries(Object.entries(config.digests).sort(([a], [b]) => a.localeCompare(b))),
|
|
233
213
|
};
|
|
234
|
-
|
|
235
|
-
return path;
|
|
214
|
+
return `${JSON.stringify(ordered, undefined, JSON_INDENT)}${NEWLINE}`;
|
|
236
215
|
}
|
|
237
216
|
|
|
238
217
|
// Seeded once, on the install that finds no config. The commands are a guess
|
|
@@ -242,7 +221,7 @@ export async function seedConfig(targetRoot: string): Promise<RepoConfig> {
|
|
|
242
221
|
return { schema_version: CONFIG_SCHEMA_VERSION, orly_version: "", packs: [], commands: await sniffCommands(targetRoot), surfaces: undefined, managed: [], digests: {} };
|
|
243
222
|
}
|
|
244
223
|
|
|
245
|
-
// What this repository installed and runs with, read from its own `.
|
|
224
|
+
// What this repository installed and runs with, read from its own `.orly/`.
|
|
246
225
|
// Every caller that used to ask a central registry "which profile is this
|
|
247
226
|
// checkout" asks the checkout instead, so the answer travels with the clone.
|
|
248
227
|
export async function localSelection(model: RulesModel, targetRoot: string): Promise<{ packs: string[]; commands: Record<string, string[][]>; surfaces: JsonObject | undefined }> {
|
|
@@ -250,123 +229,6 @@ export async function localSelection(model: RulesModel, targetRoot: string): Pro
|
|
|
250
229
|
return { packs: selectPacks(model, targetRoot, config?.packs ?? [], new Set(config?.managed ?? [])), commands: config?.commands ?? {}, surfaces: config?.surfaces };
|
|
251
230
|
}
|
|
252
231
|
|
|
253
|
-
// Which packs this repository takes: every always-on pack, the language packs
|
|
254
|
-
// whose extensions actually appear in its tree, plus whatever it opted into.
|
|
255
|
-
// No profile name, no central registry — the repository decides by its own
|
|
256
|
-
// contents, so a fresh clone installs correctly with nothing to look up.
|
|
257
|
-
export function selectPacks(model: RulesModel, targetRoot: string, requested: string[], managed: Set<string> = new Set()): string[] {
|
|
258
|
-
const packs = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
|
|
259
|
-
const present = scanExtensions(targetRoot, managed);
|
|
260
|
-
const selected = new Set<string>();
|
|
261
|
-
for (const [name, value] of Object.entries(packs)) {
|
|
262
|
-
if (OPT_IN_PACKS.includes(name)) continue;
|
|
263
|
-
const extensions = isObject(value) ? stringArray(value[EXTENSIONS_FIELD] ?? [], `pack ${name} ${EXTENSIONS_FIELD}`) : [];
|
|
264
|
-
if (extensions.length === 0 || extensions.some((extension) => present.has(extension))) selected.add(name);
|
|
265
|
-
}
|
|
266
|
-
for (const name of requested) {
|
|
267
|
-
if (!(name in packs)) throw new OrlyError(`${CONFIG_PATH} names an unknown pack: ${name} (available: ${Object.keys(packs).sort().join(", ")})`);
|
|
268
|
-
selected.add(name);
|
|
269
|
-
}
|
|
270
|
-
return [...selected].sort();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Every file extension the repository's OWN source uses. Bounded in depth and
|
|
274
|
-
// blind to dependency directories, so the walk stays cheap on a large tree.
|
|
275
|
-
// Files orly itself materialised are excluded: the gate scripts it writes are
|
|
276
|
-
// shell, so counting them would select the shell pack on the second install,
|
|
277
|
-
// which writes more shell, which selects more — a set that never settles.
|
|
278
|
-
function scanExtensions(targetRoot: string, managed: Set<string>, depth = SCAN_DEPTH): Set<string> {
|
|
279
|
-
const found = new Set<string>();
|
|
280
|
-
const walk = (directory: string, remaining: number): void => {
|
|
281
|
-
let entries;
|
|
282
|
-
try {
|
|
283
|
-
entries = readdirSync(directory, { withFileTypes: true });
|
|
284
|
-
} catch {
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
for (const entry of entries) {
|
|
288
|
-
if (entry.isDirectory()) {
|
|
289
|
-
if (remaining > 0 && !SKIPPED_DIRECTORIES.has(entry.name)) walk(join(directory, entry.name), remaining - 1);
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
const relativePath = relative(targetRoot, join(directory, entry.name)).replaceAll("\\", "/");
|
|
293
|
-
if (managed.has(relativePath)) continue;
|
|
294
|
-
const extension = extname(entry.name);
|
|
295
|
-
if (extension.length > 0) found.add(extension);
|
|
296
|
-
}
|
|
297
|
-
};
|
|
298
|
-
walk(targetRoot, depth);
|
|
299
|
-
return found;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async function sniffCommands(targetRoot: string): Promise<Record<string, string[][]>> {
|
|
303
|
-
const make = await makeTargets(targetRoot);
|
|
304
|
-
const scripts = await packageScripts(targetRoot);
|
|
305
|
-
const commands: Record<string, string[][]> = {};
|
|
306
|
-
const conform = pick(CONFORM_TARGETS, make, scripts);
|
|
307
|
-
if (conform) commands[CONFORM_COMMAND] = [conform];
|
|
308
|
-
for (const [key, candidates] of VERIFY_TARGETS) {
|
|
309
|
-
const found = pick(candidates, make, scripts);
|
|
310
|
-
// A repository with only `lint` matches both conform and verify.lint. The
|
|
311
|
-
// gate would then run one command twice, in two tiers, for one signal.
|
|
312
|
-
if (found && !(conform && sameInvocation(found, conform))) commands[key] = [found];
|
|
313
|
-
}
|
|
314
|
-
return commands;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function sameInvocation(left: string[], right: string[]): boolean {
|
|
318
|
-
return left.length === right.length && left.every((argument, index) => argument === right[index]);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function pick(candidates: string[], make: Set<string>, scripts: Set<string>): string[] | undefined {
|
|
322
|
-
for (const candidate of candidates) {
|
|
323
|
-
if (make.has(candidate)) return [MAKE_COMMAND, candidate];
|
|
324
|
-
if (scripts.has(candidate)) return [BUN_COMMAND, RUN_SUBCOMMAND, candidate];
|
|
325
|
-
}
|
|
326
|
-
return undefined;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Target names as the Makefile declares them: a line starting at column zero,
|
|
330
|
-
// up to the first colon. `include` is followed one level deep — a modular
|
|
331
|
-
// Makefile keeps every real target in make/*.mk, so a root-only read finds
|
|
332
|
-
// nothing but `help`. Enough to know a target exists, which is all the seed
|
|
333
|
-
// needs; the agent completing the config reads the file properly.
|
|
334
|
-
async function makeTargets(targetRoot: string): Promise<Set<string>> {
|
|
335
|
-
const targets = new Set<string>();
|
|
336
|
-
const root = join(targetRoot, MAKEFILE);
|
|
337
|
-
if (!existsSync(root)) return targets;
|
|
338
|
-
const files = [root, ...(await includedMakefiles(targetRoot, root))];
|
|
339
|
-
for (const file of files) {
|
|
340
|
-
if (!existsSync(file)) continue;
|
|
341
|
-
for (const line of (await Bun.file(file).text()).split(NEWLINE)) {
|
|
342
|
-
const match = TARGET_PATTERN.exec(line);
|
|
343
|
-
if (match?.[1]) targets.add(match[1]);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return targets;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
async function includedMakefiles(targetRoot: string, root: string): Promise<string[]> {
|
|
350
|
-
const included: string[] = [];
|
|
351
|
-
for (const line of (await Bun.file(root).text()).split(NEWLINE)) {
|
|
352
|
-
const match = INCLUDE_PATTERN.exec(line);
|
|
353
|
-
if (match?.[1]) included.push(join(targetRoot, match[1].trim()));
|
|
354
|
-
}
|
|
355
|
-
return included;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function packageScripts(targetRoot: string): Promise<Set<string>> {
|
|
359
|
-
const path = join(targetRoot, PACKAGE_MANIFEST);
|
|
360
|
-
if (!existsSync(path)) return new Set();
|
|
361
|
-
try {
|
|
362
|
-
const manifest = await readJsonObject(path);
|
|
363
|
-
const scripts = manifest[SCRIPTS_FIELD];
|
|
364
|
-
return isObject(scripts) ? new Set(Object.keys(scripts)) : new Set();
|
|
365
|
-
} catch {
|
|
366
|
-
return new Set();
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
232
|
function readCommands(value: unknown): Record<string, string[][]> {
|
|
371
233
|
if (value === undefined) return {};
|
|
372
234
|
const commands: Record<string, string[][]> = {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { extname, join, relative } from "node:path";
|
|
3
|
+
import { isObject, objectValue, OrlyError, readJsonObject, stringArray, type RulesModel } from "./model";
|
|
4
|
+
import { CONFIG_PATH } from "./config";
|
|
5
|
+
|
|
6
|
+
const NEWLINE = "\n";
|
|
7
|
+
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
8
|
+
const EXTENSIONS_FIELD = "extensions";
|
|
9
|
+
const CONFORM_COMMAND = "conform";
|
|
10
|
+
const MAKE_COMMAND = "make";
|
|
11
|
+
const BUN_COMMAND = "bun";
|
|
12
|
+
const RUN_SUBCOMMAND = "run";
|
|
13
|
+
const MAKEFILE = "Makefile";
|
|
14
|
+
const PACKAGE_MANIFEST = "package.json";
|
|
15
|
+
const SCRIPTS_FIELD = "scripts";
|
|
16
|
+
const LINT_ALL_TARGET = "lint-all";
|
|
17
|
+
const LINT_TARGET = "lint";
|
|
18
|
+
const BUILD_TARGET = "build";
|
|
19
|
+
const TARGET_PATTERN = /^([A-Za-z0-9][A-Za-z0-9_.-]*):/;
|
|
20
|
+
const INCLUDE_PATTERN = /^-?include\s+(\S+)/;
|
|
21
|
+
|
|
22
|
+
// Packs a repository only receives when it asks: Kishore's own address handles
|
|
23
|
+
// and agentsfleet's product surface mean nothing in a stranger's checkout, so
|
|
24
|
+
// they are never inferred — `.orly/orly.json` names them or they stay out.
|
|
25
|
+
const OPT_IN_PACKS = ["persona.indy", "product.agentsfleet", "workflow.governance"];
|
|
26
|
+
|
|
27
|
+
// Directories that are never the repository's own source: scanning them makes
|
|
28
|
+
// a Rust crate look like it writes TypeScript because one dependency does.
|
|
29
|
+
const SKIPPED_DIRECTORIES = new Set([".git", ".oracle", ".orly", "node_modules", "vendor", "third_party", "target", "dist", "build", ".zig-cache", "zig-out", ".venv", "__pycache__", ".next", ".turbo", ".cache", "coverage", "out"]);
|
|
30
|
+
const SCAN_DEPTH = 4;
|
|
31
|
+
|
|
32
|
+
// A Makefile target or package script orly knows how to map onto a gate
|
|
33
|
+
// command. The first match wins, so the more specific name is listed first.
|
|
34
|
+
const CONFORM_TARGETS = ["harness-verify", "conform", "audit", LINT_ALL_TARGET, LINT_TARGET];
|
|
35
|
+
const VERIFY_TARGETS: Array<[string, string[]]> = [
|
|
36
|
+
["verify.lint", [LINT_ALL_TARGET, LINT_TARGET]],
|
|
37
|
+
["verify.unit", ["test-unit-all", "test-unit", "test"]],
|
|
38
|
+
["verify.integration", ["test-integration"]],
|
|
39
|
+
["verify.memory", ["memleak"]],
|
|
40
|
+
["verify.version", ["check-version"]],
|
|
41
|
+
["verify.build", [BUILD_TARGET]],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Which packs this repository takes: every always-on pack, the language packs
|
|
45
|
+
// whose extensions actually appear in its tree, plus whatever it opted into.
|
|
46
|
+
// No profile name, no central registry — the repository decides by its own
|
|
47
|
+
// contents, so a fresh clone installs correctly with nothing to look up.
|
|
48
|
+
export function selectPacks(model: RulesModel, targetRoot: string, requested: string[], managed: Set<string> = new Set()): string[] {
|
|
49
|
+
const packs = objectValue(model.registry.packs, REGISTRY_PACKS_LABEL);
|
|
50
|
+
const present = scanExtensions(targetRoot, managed);
|
|
51
|
+
const selected = new Set<string>();
|
|
52
|
+
for (const [name, value] of Object.entries(packs)) {
|
|
53
|
+
if (OPT_IN_PACKS.includes(name)) continue;
|
|
54
|
+
const extensions = isObject(value) ? stringArray(value[EXTENSIONS_FIELD] ?? [], `pack ${name} ${EXTENSIONS_FIELD}`) : [];
|
|
55
|
+
if (extensions.length === 0 || extensions.some((extension) => present.has(extension))) selected.add(name);
|
|
56
|
+
}
|
|
57
|
+
for (const name of requested) {
|
|
58
|
+
if (!(name in packs)) throw new OrlyError(`${CONFIG_PATH} names an unknown pack: ${name} (available: ${Object.keys(packs).sort().join(", ")})`);
|
|
59
|
+
selected.add(name);
|
|
60
|
+
}
|
|
61
|
+
return [...selected].sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Every file extension the repository's OWN source uses. Bounded in depth and
|
|
65
|
+
// blind to dependency directories, so the walk stays cheap on a large tree.
|
|
66
|
+
// Files orly itself materialised are excluded: the gate scripts it writes are
|
|
67
|
+
// shell, so counting them would select the shell pack on the second install,
|
|
68
|
+
// which writes more shell, which selects more — a set that never settles.
|
|
69
|
+
function scanExtensions(targetRoot: string, managed: Set<string>, depth = SCAN_DEPTH): Set<string> {
|
|
70
|
+
const found = new Set<string>();
|
|
71
|
+
const walk = (directory: string, remaining: number): void => {
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (remaining > 0 && !SKIPPED_DIRECTORIES.has(entry.name)) walk(join(directory, entry.name), remaining - 1);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const relativePath = relative(targetRoot, join(directory, entry.name)).replaceAll("\\", "/");
|
|
84
|
+
if (managed.has(relativePath)) continue;
|
|
85
|
+
const extension = extname(entry.name);
|
|
86
|
+
if (extension.length > 0) found.add(extension);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(targetRoot, depth);
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function sniffCommands(targetRoot: string): Promise<Record<string, string[][]>> {
|
|
94
|
+
const make = await makeTargets(targetRoot);
|
|
95
|
+
const scripts = await packageScripts(targetRoot);
|
|
96
|
+
const commands: Record<string, string[][]> = {};
|
|
97
|
+
const conform = pick(CONFORM_TARGETS, make, scripts);
|
|
98
|
+
if (conform) commands[CONFORM_COMMAND] = [conform];
|
|
99
|
+
for (const [key, candidates] of VERIFY_TARGETS) {
|
|
100
|
+
const found = pick(candidates, make, scripts);
|
|
101
|
+
// A repository with only `lint` matches both conform and verify.lint. The
|
|
102
|
+
// gate would then run one command twice, in two tiers, for one signal.
|
|
103
|
+
if (found && !(conform && sameInvocation(found, conform))) commands[key] = [found];
|
|
104
|
+
}
|
|
105
|
+
return commands;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sameInvocation(left: string[], right: string[]): boolean {
|
|
109
|
+
return left.length === right.length && left.every((argument, index) => argument === right[index]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pick(candidates: string[], make: Set<string>, scripts: Set<string>): string[] | undefined {
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (make.has(candidate)) return [MAKE_COMMAND, candidate];
|
|
115
|
+
if (scripts.has(candidate)) return [BUN_COMMAND, RUN_SUBCOMMAND, candidate];
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Target names as the Makefile declares them: a line starting at column zero,
|
|
121
|
+
// up to the first colon. `include` is followed one level deep — a modular
|
|
122
|
+
// Makefile keeps every real target in make/*.mk, so a root-only read finds
|
|
123
|
+
// nothing but `help`. Enough to know a target exists, which is all the seed
|
|
124
|
+
// needs; the agent completing the config reads the file properly.
|
|
125
|
+
async function makeTargets(targetRoot: string): Promise<Set<string>> {
|
|
126
|
+
const targets = new Set<string>();
|
|
127
|
+
const root = join(targetRoot, MAKEFILE);
|
|
128
|
+
if (!existsSync(root)) return targets;
|
|
129
|
+
const files = [root, ...(await includedMakefiles(targetRoot, root))];
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
if (!existsSync(file)) continue;
|
|
132
|
+
for (const line of (await Bun.file(file).text()).split(NEWLINE)) {
|
|
133
|
+
const match = TARGET_PATTERN.exec(line);
|
|
134
|
+
if (match?.[1]) targets.add(match[1]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return targets;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function includedMakefiles(targetRoot: string, root: string): Promise<string[]> {
|
|
141
|
+
const included: string[] = [];
|
|
142
|
+
for (const line of (await Bun.file(root).text()).split(NEWLINE)) {
|
|
143
|
+
const match = INCLUDE_PATTERN.exec(line);
|
|
144
|
+
if (match?.[1]) included.push(join(targetRoot, match[1].trim()));
|
|
145
|
+
}
|
|
146
|
+
return included;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function packageScripts(targetRoot: string): Promise<Set<string>> {
|
|
150
|
+
const path = join(targetRoot, PACKAGE_MANIFEST);
|
|
151
|
+
if (!existsSync(path)) return new Set();
|
|
152
|
+
try {
|
|
153
|
+
const manifest = await readJsonObject(path);
|
|
154
|
+
const scripts = manifest[SCRIPTS_FIELD];
|
|
155
|
+
return isObject(scripts) ? new Set(Object.keys(scripts)) : new Set();
|
|
156
|
+
} catch {
|
|
157
|
+
return new Set();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { CONFIG_PATH, parseConfig, readConfig, readConfigSync, serialiseConfig } from "./config";
|
|
7
|
+
import { limitsFor, DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_OUTPUT_BYTES } from "./command_limits";
|
|
8
|
+
import type { JsonObject } from "./model";
|
|
9
|
+
|
|
10
|
+
const roots: string[] = [];
|
|
11
|
+
const UNIT_LANE = "verify.unit";
|
|
12
|
+
const BASE = { schema_version: 1, packs: [], commands: { conform: [["true"]], [UNIT_LANE]: [["true"]] } };
|
|
13
|
+
|
|
14
|
+
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
15
|
+
|
|
16
|
+
for (const [field, value] of [
|
|
17
|
+
["packs", null], ["packs", [false]], ["managed", {}], ["managed", [""]],
|
|
18
|
+
["digests", []], ["digests", { path: false }], ["digests", { path: "broken" }],
|
|
19
|
+
["orly_version", {}], ["orly_version", "yesterday"], ["schema_version", 2],
|
|
20
|
+
["unknown_setting", true], ["limits", []], ["limits", { [UNIT_LANE]: { timeout_ms: 0 } }],
|
|
21
|
+
["limits", { [UNIT_LANE]: { output_bytes: -1 } }], ["limits", { [UNIT_LANE]: { timeout_ms: 1.5 } }],
|
|
22
|
+
["limits", { [UNIT_LANE]: { typo: 10 } }], ["limits", { [UNIT_LANE]: null }],
|
|
23
|
+
] as Array<[string, unknown]>) {
|
|
24
|
+
test(`both configuration readers reject invalid ${field} value ${JSON.stringify(value)}`, async () => {
|
|
25
|
+
const root = mkdtempSync(join(tmpdir(), "orly-config-validation-"));
|
|
26
|
+
roots.push(root);
|
|
27
|
+
mkdirSync(join(root, ".orly"));
|
|
28
|
+
writeFileSync(join(root, CONFIG_PATH), JSON.stringify({ ...BASE, [field]: value }));
|
|
29
|
+
expect(() => readConfigSync(root)).toThrow(field === "unknown_setting" ? "Unrecognized key" : field);
|
|
30
|
+
await expect(readConfig(root)).rejects.toThrow(field === "unknown_setting" ? "Unrecognized key" : field);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("configuration limits survive a rewrite and override only their named lane", () => {
|
|
35
|
+
const limits = { [UNIT_LANE]: { timeout_ms: 200, output_bytes: 4096 }, conform: { timeout_ms: 500 } };
|
|
36
|
+
const config = parseConfig({ ...BASE, limits });
|
|
37
|
+
expect(parseConfig(JSON.parse(serialiseConfig(config)))).toEqual(config);
|
|
38
|
+
expect(limitsFor(config.limits, UNIT_LANE)).toEqual(limits[UNIT_LANE]);
|
|
39
|
+
expect(limitsFor(config.limits, "conform")).toEqual({ timeout_ms: 500, output_bytes: DEFAULT_COMMAND_OUTPUT_BYTES });
|
|
40
|
+
expect(limitsFor(config.limits, "verify.integration")).toEqual({ timeout_ms: DEFAULT_COMMAND_TIMEOUT_MS, output_bytes: DEFAULT_COMMAND_OUTPUT_BYTES });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a minimal seeded configuration normalizes optional ownership records", () => {
|
|
44
|
+
const config = parseConfig({ schema_version: 1 });
|
|
45
|
+
expect(config.packs).toEqual([]);
|
|
46
|
+
expect(config.managed).toEqual([]);
|
|
47
|
+
expect(config.digests).toEqual({});
|
|
48
|
+
expect(config.orly_version).toBe("");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("disabled execution settings round-trip while active remote execution refuses", () => {
|
|
52
|
+
const disabled: JsonObject = { ...BASE, execution: { remote: { mode: "disabled", launcher: ["sentinel"] } } };
|
|
53
|
+
const config = parseConfig(disabled);
|
|
54
|
+
expect(parseConfig(JSON.parse(serialiseConfig(config)))).toEqual(config);
|
|
55
|
+
expect(() => parseConfig({ ...BASE, execution: { remote: { mode: "enabled" } } })).toThrow("unsupported");
|
|
56
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { commandLimitsSchema } from "./command_limits";
|
|
4
|
+
import { OrlyError, type JsonObject } from "./model";
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = 1;
|
|
7
|
+
const DIGEST_PATTERN = /^(?:sha256:)?[a-f0-9]{64}$/;
|
|
8
|
+
const VERSION_PATTERN = /^$|^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/;
|
|
9
|
+
const nonempty = z.string().min(1).refine((value) => !value.includes("\0"));
|
|
10
|
+
const configurationSchema = z.object({
|
|
11
|
+
schema_version: z.literal(SCHEMA_VERSION),
|
|
12
|
+
orly_version: z.string().regex(VERSION_PATTERN).optional(),
|
|
13
|
+
packs: z.array(nonempty).optional(),
|
|
14
|
+
commands: z.unknown().optional(),
|
|
15
|
+
surfaces: z.unknown().optional(),
|
|
16
|
+
managed: z.array(nonempty).optional(),
|
|
17
|
+
digests: z.record(nonempty, z.string().regex(DIGEST_PATTERN)).optional(),
|
|
18
|
+
execution: z.unknown().optional(),
|
|
19
|
+
limits: z.record(nonempty, commandLimitsSchema).optional(),
|
|
20
|
+
}).strict();
|
|
21
|
+
|
|
22
|
+
export function validateConfiguration(value: JsonObject): void {
|
|
23
|
+
const parsed = configurationSchema.safeParse(value);
|
|
24
|
+
if (!parsed.success) throw new OrlyError(`.orly/orly.json invalid repository configuration: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ")}`);
|
|
25
|
+
}
|
package/src/criteria.test.ts
CHANGED
|
@@ -159,7 +159,7 @@ describe("repo.config", () => {
|
|
|
159
159
|
test("a repository with no config names the install command", async () => {
|
|
160
160
|
const project = newSpecRepository();
|
|
161
161
|
const context = { root: project, model: await modelFor(project), acceptDirty: false };
|
|
162
|
-
rmSync(join(project, ".
|
|
162
|
+
rmSync(join(project, ".orly/orly.json"));
|
|
163
163
|
const criterion = criteriaFor(WORK, context).find((entry) => entry.name === REPO_CONFIG);
|
|
164
164
|
const verdict = criterion?.evaluate(context);
|
|
165
165
|
expect(verdict?.ok).toBeFalse();
|
package/src/criteria.ts
CHANGED
|
@@ -12,16 +12,16 @@ import { documentationSurfaces, scanSurfaces } from "./doc_rules";
|
|
|
12
12
|
import { isObject, JsonObject, objectValue, OrlyError, RulesModel } from "./model";
|
|
13
13
|
import { readConfigSync, RepoConfig } from "./config";
|
|
14
14
|
import { classifyBranch, SurfaceReport } from "./surfaces";
|
|
15
|
+
import { selectedCommands, CONDITIONAL_LANES, CONFORM_TIER, FAST_TIER, ALL_TIER, type CommandTier } from "./execution/plan";
|
|
15
16
|
import { commandSetupErrors } from "./validation";
|
|
17
|
+
import { limitsFor, type CommandLimits } from "./command_limits";
|
|
16
18
|
|
|
17
19
|
export type { Criterion, CriterionContext, CriterionResult, Verdict };
|
|
18
20
|
export { runCommand };
|
|
19
21
|
|
|
20
22
|
const DEFAULT_BRANCHES = ["master", "main"];
|
|
21
|
-
const CONFORM_COMMAND = "conform";
|
|
22
|
-
const VERIFY_PREFIX = "verify.";
|
|
23
23
|
const REPOSITORIES_LABEL = "repositories";
|
|
24
|
-
const UNINSTALLED = "no .
|
|
24
|
+
const UNINSTALLED = "no .orly/orly.json here — run `orly init` first";
|
|
25
25
|
const REV_PARSE = "rev-parse";
|
|
26
26
|
const HEAD = "HEAD";
|
|
27
27
|
const UPSTREAM = "@{upstream}";
|
|
@@ -45,15 +45,6 @@ const NAMED_FINDINGS = 3;
|
|
|
45
45
|
// rule audit could only run beside the lint and unit suites, so a repository
|
|
46
46
|
// paid minutes to learn something its seconds-long conform command already
|
|
47
47
|
// knew, and `orly gate` ran it twice on the way to `pr`.
|
|
48
|
-
const CONFORM_TIER = "conform";
|
|
49
|
-
const FAST_TIER = "fast";
|
|
50
|
-
const SLOW_TIER = "slow";
|
|
51
|
-
// The slow tier is a fixed name set, not a prefix rule: lint and version
|
|
52
|
-
// checks are verify.* too, and demoting them to skip-on-prose would be wrong.
|
|
53
|
-
const SLOW_COMMANDS = ["verify.integration", "verify.memory"];
|
|
54
|
-
const UNIT_COMMAND = "verify.unit";
|
|
55
|
-
const ALL_TIER = "all";
|
|
56
|
-
|
|
57
48
|
// Commit checks read the index. Push checks permit in-flight Sections and omit
|
|
58
49
|
// the boundary test suites. The final gate runs all declared verification
|
|
59
50
|
// itself, including checks a custom repository hook may not have invoked.
|
|
@@ -115,30 +106,23 @@ function repositoryConfig(): Criterion {
|
|
|
115
106
|
|
|
116
107
|
// Commands and source paths are repository-owned. Only integration and memory
|
|
117
108
|
// checks are conditional on code; every other declared lane always runs.
|
|
118
|
-
function commandCriteria(context: CriterionContext, tier:
|
|
109
|
+
function commandCriteria(context: CriterionContext, tier: CommandTier): Criterion[] {
|
|
119
110
|
const config = resolvedConfig(context);
|
|
120
111
|
if (!config) return [];
|
|
121
112
|
const commands = config.commands;
|
|
122
|
-
const selected =
|
|
113
|
+
const selected = selectedCommands(commands, tier);
|
|
123
114
|
return selected.map((key) => criterion(`cmd.${key}`, (inner) => {
|
|
124
|
-
if (
|
|
115
|
+
if (CONDITIONAL_LANES.includes(key) && report(inner, config.surfaces).code.length === 0) {
|
|
125
116
|
return { ok: true, detail: "skipped — no code files on this branch" };
|
|
126
117
|
}
|
|
127
|
-
return runInvocations(inner.root, commands[key]);
|
|
118
|
+
return runInvocations(inner.root, commands[key], limitsFor(config.limits, key));
|
|
128
119
|
}));
|
|
129
120
|
}
|
|
130
121
|
|
|
131
|
-
function tierOf(key: string): string {
|
|
132
|
-
if (key === CONFORM_COMMAND) return CONFORM_TIER;
|
|
133
|
-
if (key === UNIT_COMMAND || SLOW_COMMANDS.includes(key)) return SLOW_TIER;
|
|
134
|
-
if (key.startsWith(VERIFY_PREFIX)) return FAST_TIER;
|
|
135
|
-
return "";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
122
|
function docsUpdated(): Criterion {
|
|
139
123
|
return criterion(DOCS_UPDATED, (context) => {
|
|
140
124
|
const config = resolvedConfig(context);
|
|
141
|
-
if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .
|
|
125
|
+
if (!config?.surfaces) return { ok: true, detail: "no user surface declared in .orly/orly.json" };
|
|
142
126
|
const surfaces = report(context, config.surfaces);
|
|
143
127
|
if (surfaces.userSurface.length === 0) return { ok: true, detail: "no user-surface files on this branch" };
|
|
144
128
|
if (surfaces.docs.length > 0) return { ok: true, detail: `${surfaces.userSurface.length} user-surface file(s), ${surfaces.docs.length} docs file(s) updated` };
|
|
@@ -191,13 +175,13 @@ function resolvedConfig(context: CriterionContext): RepoConfig | undefined {
|
|
|
191
175
|
}
|
|
192
176
|
}
|
|
193
177
|
|
|
194
|
-
function runInvocations(root: string, invocations: unknown): Verdict {
|
|
178
|
+
function runInvocations(root: string, invocations: unknown, limits: CommandLimits): Verdict {
|
|
195
179
|
if (!Array.isArray(invocations) || invocations.length === 0) return { ok: false, detail: "command group is empty" };
|
|
196
180
|
const evidence: string[] = [];
|
|
197
181
|
for (const invocation of invocations) {
|
|
198
182
|
if (!Array.isArray(invocation) || invocation.length === 0) return { ok: false, detail: "command invocation is empty" };
|
|
199
183
|
const argv = invocation.map((argument) => String(argument));
|
|
200
|
-
const result = runCommand(root, argv);
|
|
184
|
+
const result = runCommand(root, argv, limits);
|
|
201
185
|
evidence.push(`${argv.join(" ")} -> ${result.detail}`);
|
|
202
186
|
if (!result.ok) return { ok: false, detail: evidence.join(NEWLINE) };
|
|
203
187
|
}
|