@agentsfleet/orly 0.10.14 → 0.12.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 +94 -28
- 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.md +10 -0
- 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 +156 -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 +46 -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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { copyFileSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { cleanupTemporaryDirectories, newRepository, ROOT } from "./gates_test_support";
|
|
6
|
+
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
7
|
+
|
|
8
|
+
const SCRIPT = "evals/llms/run.sh";
|
|
9
|
+
const AGENT_NAMES = ["claude", "codex", "amp", "opencode"];
|
|
10
|
+
const CALLS = "calls.txt";
|
|
11
|
+
const AGENTS = "AGENTS.md";
|
|
12
|
+
const FACADE = "dispatch/write_fixture.md";
|
|
13
|
+
const PIPE_OUTPUT = "pipe";
|
|
14
|
+
const TEXT_ENCODING = "utf8";
|
|
15
|
+
const CASE_TIMEOUT_MS = 30_000;
|
|
16
|
+
|
|
17
|
+
afterEach(cleanupTemporaryDirectories);
|
|
18
|
+
|
|
19
|
+
function fixture(missingHost = false) {
|
|
20
|
+
const root = newRepository();
|
|
21
|
+
for (const file of [SCRIPT, "evals/llms/agents.sh", "evals/llms/fixtures.sh", "evals/llms/cache.ts", "skills/orly-write-unit-test/SKILL.md", "skills/orly-write-integration-test/SKILL.md"]) {
|
|
22
|
+
mkdirSync(join(root, file, ".."), { recursive: true });
|
|
23
|
+
copyFileSync(join(ROOT, file), join(root, file));
|
|
24
|
+
}
|
|
25
|
+
mkdirSync(join(root, "dispatch"));
|
|
26
|
+
writeFileSync(join(root, AGENTS), "# Deterministic rules\n");
|
|
27
|
+
writeFileSync(join(root, FACADE), "Require the declared check.\n");
|
|
28
|
+
writeFileSync(join(root, "evals/llms/fixtures.jsonl"), JSON.stringify({ id: "one", mode: "control", expect: "YES", q: "Does the rule require its check?", why: "Local adapter control.", ctx: [FACADE] }) + "\n");
|
|
29
|
+
const tools = join(root, "tools");
|
|
30
|
+
mkdirSync(tools);
|
|
31
|
+
for (const name of ["bash", "sh", "bun", "python3", "timeout", "dirname", "cat", "mktemp", "rm", "ln", "chmod", "cut", "grep", "tr", "sed", "awk", "mkdir", "basename", "git"]) {
|
|
32
|
+
const target = Bun.which(name);
|
|
33
|
+
if (target) symlinkSync(target, join(tools, name));
|
|
34
|
+
}
|
|
35
|
+
for (const host of AGENT_NAMES.filter((name) => !missingHost || name !== "opencode")) {
|
|
36
|
+
writeFileSync(join(tools, host), `#!/bin/sh
|
|
37
|
+
printf '%s\n' '${host}' >> "$ORLY_STUB_CALLS"
|
|
38
|
+
answer=YES
|
|
39
|
+
if [ '${host}' = opencode ] && [ "\${ORLY_STUB_FAIL:-0}" = 1 ]; then answer=NO; fi
|
|
40
|
+
if [ '${host}' = codex ]; then
|
|
41
|
+
cat >/dev/null
|
|
42
|
+
while [ "$#" -gt 0 ]; do
|
|
43
|
+
if [ "$1" = --output-last-message ]; then shift; printf 'VERDICT: %s\n' "$answer" > "$1"; break; fi
|
|
44
|
+
shift
|
|
45
|
+
done
|
|
46
|
+
else
|
|
47
|
+
if [ '${host}' != opencode ]; then cat >/dev/null; fi
|
|
48
|
+
printf 'VERDICT: %s\n' "$answer"
|
|
49
|
+
fi
|
|
50
|
+
`, { mode: 0o755 });
|
|
51
|
+
}
|
|
52
|
+
const env = { ...UNSCOPED_ENVIRONMENT, PATH: tools, ORLY_STUB_CALLS: join(root, CALLS) };
|
|
53
|
+
return { root, env };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function run(f: ReturnType<typeof fixture>, args: string[] = [], fail = true) {
|
|
57
|
+
const result = Bun.spawnSync([Bun.which("bash")!, join(f.root, SCRIPT), ...args], {
|
|
58
|
+
cwd: f.root, env: { ...f.env, ORLY_STUB_FAIL: fail ? "1" : "0" }, stdout: PIPE_OUTPUT, stderr: PIPE_OUTPUT, timeout: 30_000,
|
|
59
|
+
});
|
|
60
|
+
return { code: result.exitCode, output: result.stdout.toString() + result.stderr.toString() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function calls(f: ReturnType<typeof fixture>): number { return readFileSync(join(f.root, CALLS), TEXT_ENCODING).trim().split("\n").length; }
|
|
64
|
+
|
|
65
|
+
test("changed prompt bytes invalidate saved results without a new commit", () => {
|
|
66
|
+
const f = fixture();
|
|
67
|
+
const first = run(f);
|
|
68
|
+
expect(first.code, first.output).toBe(1);
|
|
69
|
+
expect(calls(f)).toBe(4);
|
|
70
|
+
expect(run(f).output).toContain("resumed from journal");
|
|
71
|
+
expect(calls(f)).toBe(4);
|
|
72
|
+
writeFileSync(join(f.root, AGENTS), "# Changed deterministic rule\n");
|
|
73
|
+
const changed = run(f);
|
|
74
|
+
expect(changed.code).toBe(1);
|
|
75
|
+
expect(changed.output).not.toContain("resumed from journal");
|
|
76
|
+
expect(calls(f)).toBe(8);
|
|
77
|
+
writeFileSync(join(f.root, FACADE), "A changed check remains required.\n");
|
|
78
|
+
expect(run(f).output).not.toContain("resumed from journal");
|
|
79
|
+
expect(calls(f)).toBe(12);
|
|
80
|
+
}, CASE_TIMEOUT_MS);
|
|
81
|
+
|
|
82
|
+
test("host selection and adapter settings cannot reuse incompatible saved proof", () => {
|
|
83
|
+
const f = fixture();
|
|
84
|
+
run(f);
|
|
85
|
+
const partial = run(f, ["--agent", "claude"]);
|
|
86
|
+
expect(partial.code).toBe(0);
|
|
87
|
+
expect(partial.output).toContain("partial comprehension coverage");
|
|
88
|
+
expect(partial.output).not.toContain("live comprehension passed");
|
|
89
|
+
expect(calls(f)).toBe(5);
|
|
90
|
+
mkdirSync(join(f.root, ".claude"));
|
|
91
|
+
writeFileSync(join(f.root, ".claude/settings.json"), JSON.stringify({ model: "changed-local-control" }));
|
|
92
|
+
expect(run(f).output).not.toContain("resumed from journal");
|
|
93
|
+
expect(calls(f)).toBe(9);
|
|
94
|
+
}, CASE_TIMEOUT_MS);
|
|
95
|
+
|
|
96
|
+
test("malformed journal counts are discarded instead of becoming a pass", () => {
|
|
97
|
+
const f = fixture();
|
|
98
|
+
run(f);
|
|
99
|
+
const journalRoot = join(f.root, ".llmevals-journal");
|
|
100
|
+
const runDirectory = join(journalRoot, readdirSync(journalRoot)[0]!);
|
|
101
|
+
const path = join(runDirectory, "claude");
|
|
102
|
+
const saved = JSON.parse(readFileSync(path, TEXT_ENCODING));
|
|
103
|
+
writeFileSync(path, JSON.stringify({ ...saved, status: "PASS", correct: 0, total: 0 }));
|
|
104
|
+
expect(run(f).code).toBe(1);
|
|
105
|
+
expect(calls(f)).toBe(5);
|
|
106
|
+
}, CASE_TIMEOUT_MS);
|
|
107
|
+
|
|
108
|
+
test("a missing required host keeps full coverage incomplete", () => {
|
|
109
|
+
const f = fixture(true);
|
|
110
|
+
const result = run(f, [], false);
|
|
111
|
+
expect(result.code).toBe(1);
|
|
112
|
+
expect(result.output).toContain("requested agent absent: opencode");
|
|
113
|
+
expect(result.output).not.toContain("live comprehension passed");
|
|
114
|
+
expect(calls(f)).toBe(3);
|
|
115
|
+
}, CASE_TIMEOUT_MS);
|
|
116
|
+
|
|
117
|
+
test("full completion requires every selected fixture on all required local stub hosts", () => {
|
|
118
|
+
const f = fixture();
|
|
119
|
+
const result = run(f, [], false);
|
|
120
|
+
expect(result.code).toBe(0);
|
|
121
|
+
expect(result.output).toContain("graded=4/4");
|
|
122
|
+
expect(calls(f)).toBe(4);
|
|
123
|
+
}, CASE_TIMEOUT_MS);
|
package/src/config.test.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { selectPacks } from "./config_discovery";
|
|
7
|
+
import { CONFIG_PATH, contentDigest, managedDrift, readConfig, readConfigSync, seedConfig, staleVersion, writeConfig } from "./config";
|
|
7
8
|
import { RulesModel } from "./model";
|
|
8
9
|
|
|
9
10
|
const ROOT = resolve(import.meta.dir, "..");
|
|
@@ -20,7 +21,7 @@ function scratch(): string {
|
|
|
20
21
|
const temporary: string[] = [];
|
|
21
22
|
|
|
22
23
|
function writeConfigText(root: string, body: unknown): void {
|
|
23
|
-
mkdirSync(join(root, ".
|
|
24
|
+
mkdirSync(join(root, ".orly"), { recursive: true });
|
|
24
25
|
writeFileSync(join(root, CONFIG_PATH), JSON.stringify(body));
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -236,7 +237,7 @@ describe("managedDrift", () => {
|
|
|
236
237
|
});
|
|
237
238
|
|
|
238
239
|
// The failure this pair exists for: agentsfleet ignored .claude/skills/ while
|
|
239
|
-
// .
|
|
240
|
+
// .orly/orly.json declared four files under it managed. Every local
|
|
240
241
|
// checkout passed `orly doctor` because the files sat on disk, and CI failed
|
|
241
242
|
// against a clone that never received them.
|
|
242
243
|
test("a managed file git refuses to track is drift, even though it is present", async () => {
|
|
@@ -294,7 +295,7 @@ describe("writeConfig", () => {
|
|
|
294
295
|
expect((await readConfig(root))?.managed).toEqual(["aaa.md", "zzz.md"]);
|
|
295
296
|
});
|
|
296
297
|
|
|
297
|
-
test("writeConfig creates the .
|
|
298
|
+
test("writeConfig creates the .orly/ parent directory", async () => {
|
|
298
299
|
const root = scratch();
|
|
299
300
|
await writeConfig(root, await seedConfig(root));
|
|
300
301
|
expect(existsSync(join(root, CONFIG_PATH))).toBeTrue();
|
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
|
+
});
|