@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,165 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { OrlyError } from "../model";
|
|
7
|
+
import { REPLAY_DIRECTORY } from "./constants";
|
|
8
|
+
import { prepareItem } from "./evidence";
|
|
9
|
+
import { replayDirectory } from "./replay";
|
|
10
|
+
import { judge } from "./runner";
|
|
11
|
+
import { choiceReply, SOURCE, SOURCE_FILE, TestProject } from "./test_support";
|
|
12
|
+
|
|
13
|
+
test("live evidence becomes exact offline replay without another request", async () => {
|
|
14
|
+
using project = new TestProject();
|
|
15
|
+
let requests = 0;
|
|
16
|
+
let scans = 0;
|
|
17
|
+
const dependencies = { credential: "test-credential", scan: async () => { scans++; }, fetch: async () => { requests++; return Response.json(choiceReply()); } };
|
|
18
|
+
const live = await judge(project.root, project.manifest(), true, dependencies);
|
|
19
|
+
expect(live.requests).toBe(1);
|
|
20
|
+
expect(live.results[0]).toMatchObject({ status: "complete", mode: "live", assessment: { concern: true, decision: "weak" } });
|
|
21
|
+
const replay = await judge(project.root, project.manifest(), false, dependencies);
|
|
22
|
+
expect(replay.requests).toBe(0);
|
|
23
|
+
expect(replay.results[0]).toMatchObject({ status: "complete", mode: "replay" });
|
|
24
|
+
expect(requests).toBe(1);
|
|
25
|
+
expect(scans).toBe(1);
|
|
26
|
+
expect(replay.results[0]?.status === "complete" && replay.results[0].answer).toEqual(live.results[0]?.status === "complete" && live.results[0].answer);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("missing replay never falls through to live inference", async () => {
|
|
30
|
+
using project = new TestProject();
|
|
31
|
+
let calls = 0;
|
|
32
|
+
const result = await judge(project.root, project.manifest(), false, { credential: "unused", scan: async () => { calls++; }, fetch: async () => { calls++; return Response.json(choiceReply()); } });
|
|
33
|
+
expect(result.results[0]).toMatchObject({ status: "incomplete", requests: 0 });
|
|
34
|
+
expect(result.requests).toBe(0);
|
|
35
|
+
expect(calls).toBe(0);
|
|
36
|
+
expect(existsSync(join(project.root, REPLAY_DIRECTORY))).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("missing credentials or failed scanning prevent all uploads", async () => {
|
|
40
|
+
using project = new TestProject();
|
|
41
|
+
let uploads = 0;
|
|
42
|
+
const send = async () => { uploads++; return Response.json(choiceReply()); };
|
|
43
|
+
const missing = await judge(project.root, project.manifest(), true, { credential: undefined, scan: async () => {}, fetch: send });
|
|
44
|
+
expect(missing.results[0]).toMatchObject({ status: "incomplete", requests: 0 });
|
|
45
|
+
const refused = await judge(project.root, project.manifest(), true, { credential: "test-value", scan: async () => { throw new OrlyError("Scanner refused input."); }, fetch: send });
|
|
46
|
+
expect(refused.results[0]).toMatchObject({ status: "incomplete", reason: "Scanner refused input.", requests: 0 });
|
|
47
|
+
expect(uploads).toBe(0);
|
|
48
|
+
expect(existsSync(join(project.root, REPLAY_DIRECTORY))).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("an invalid later item stops the whole batch before upload", async () => {
|
|
52
|
+
using project = new TestProject();
|
|
53
|
+
let calls = 0;
|
|
54
|
+
const item = { ...project.item(), id: "missing", evidence: project.item().evidence.map((reference) => ({ ...reference, path: "missing.ts" })) };
|
|
55
|
+
const report = await judge(project.root, { stage: "verify", items: [project.item(), item] }, true, { credential: "unused", scan: async () => { calls++; }, fetch: async () => { calls++; return Response.json(choiceReply()); } });
|
|
56
|
+
expect(report.requests).toBe(0);
|
|
57
|
+
expect(report.results.every((result) => result.status === "incomplete")).toBe(true);
|
|
58
|
+
expect(calls).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test.each([true, false])("a replay symlink stops the whole batch before upload, target exists: %s", async (targetExists) => {
|
|
62
|
+
using project = new TestProject();
|
|
63
|
+
using outside = new TestProject();
|
|
64
|
+
const blocked = { ...project.item(), id: "blocked", requirement: `${project.item().requirement} Include the requested locale.` };
|
|
65
|
+
const prepared = await prepareItem(project.root, blocked);
|
|
66
|
+
const target = join(outside.root, targetExists ? SOURCE_FILE : "absent.json");
|
|
67
|
+
symlinkSync(target, join(replayDirectory(project.root, true), `${prepared.identity}.json`));
|
|
68
|
+
let uploads = 0;
|
|
69
|
+
const report = await judge(project.root, { stage: "verify", items: [project.item(), blocked] }, true, {
|
|
70
|
+
credential: "unused", scan: async () => {}, fetch: async () => { uploads++; return Response.json(choiceReply()); },
|
|
71
|
+
});
|
|
72
|
+
expect(report.requests).toBe(0);
|
|
73
|
+
expect(uploads).toBe(0);
|
|
74
|
+
expect(report.results).toHaveLength(2);
|
|
75
|
+
for (const result of report.results) expect(result).toMatchObject({ status: "incomplete", requests: 0, reason: "Replay file must not be a symlink." });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a directory at a later replay filename stops the whole batch before upload", async () => {
|
|
79
|
+
using project = new TestProject();
|
|
80
|
+
const blocked = { ...project.item(), id: "blocked", requirement: `${project.item().requirement} Include the requested locale.` };
|
|
81
|
+
const prepared = await prepareItem(project.root, blocked);
|
|
82
|
+
mkdirSync(join(replayDirectory(project.root, true), `${prepared.identity}.json`));
|
|
83
|
+
let uploads = 0;
|
|
84
|
+
const report = await judge(project.root, { stage: "verify", items: [project.item(), blocked] }, true, {
|
|
85
|
+
credential: "unused", scan: async () => {}, fetch: async () => { uploads++; return Response.json(choiceReply()); },
|
|
86
|
+
});
|
|
87
|
+
expect(report.requests).toBe(0);
|
|
88
|
+
expect(uploads).toBe(0);
|
|
89
|
+
expect(report.results).toHaveLength(2);
|
|
90
|
+
for (const result of report.results) expect(result).toMatchObject({ status: "incomplete", requests: 0, reason: "Replay file must be a regular file." });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("invalid provider replies count the attempt and never create a valid replay", async () => {
|
|
94
|
+
using project = new TestProject();
|
|
95
|
+
const dependencies = { credential: "unused", scan: async () => {}, fetch: async () => Response.json({ model: "unexpected" }) };
|
|
96
|
+
const live = await judge(project.root, project.manifest(), true, dependencies);
|
|
97
|
+
expect(live.results[0]).toMatchObject({ status: "incomplete", requests: 1, usage: null });
|
|
98
|
+
expect(live.requests).toBe(1);
|
|
99
|
+
const replay = await judge(project.root, project.manifest(), false, dependencies);
|
|
100
|
+
expect(replay.results[0]).toMatchObject({ status: "incomplete", requests: 0 });
|
|
101
|
+
project.write(SOURCE_FILE, SOURCE.replace("DateTimeFormat(locale", 'DateTimeFormat("en-US"'));
|
|
102
|
+
expect((await judge(project.root, project.manifest(), false, dependencies)).results[0]?.status).toBe("incomplete");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a later scanner refusal leaves every item incomplete without upload", async () => {
|
|
106
|
+
using project = new TestProject();
|
|
107
|
+
const later = { ...project.item(), id: "later", requirement: `${project.item().requirement} Include the requested locale.` };
|
|
108
|
+
let scans = 0;
|
|
109
|
+
let uploads = 0;
|
|
110
|
+
const report = await judge(project.root, { stage: "verify", items: [project.item(), later] }, true, {
|
|
111
|
+
credential: "unused", scan: async () => { if (++scans === 2) throw new OrlyError("Scanner refused the later input."); },
|
|
112
|
+
fetch: async () => { uploads++; return Response.json(choiceReply()); },
|
|
113
|
+
});
|
|
114
|
+
expect(scans).toBe(2);
|
|
115
|
+
expect(uploads).toBe(0);
|
|
116
|
+
expect(report.requests).toBe(0);
|
|
117
|
+
for (const result of report.results) expect(result).toMatchObject({ status: "incomplete", requests: 0, reason: "Scanner refused the later input." });
|
|
118
|
+
expect(existsSync(join(project.root, REPLAY_DIRECTORY))).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a later provider refusal preserves earlier evidence and never retries", async () => {
|
|
122
|
+
using project = new TestProject();
|
|
123
|
+
const later = { ...project.item(), id: "later", requirement: `${project.item().requirement} Include the requested locale.` };
|
|
124
|
+
const manifest = { stage: "verify" as const, items: [project.item(), later] };
|
|
125
|
+
let uploads = 0;
|
|
126
|
+
const dependencies = {
|
|
127
|
+
credential: "unused", scan: async () => {},
|
|
128
|
+
fetch: async () => ++uploads === 2 ? new Response("private detail", { status: 503 }) : Response.json(choiceReply()),
|
|
129
|
+
};
|
|
130
|
+
const report = await judge(project.root, manifest, true, dependencies);
|
|
131
|
+
expect(uploads).toBe(2);
|
|
132
|
+
expect(report.requests).toBe(2);
|
|
133
|
+
expect(report.results[0]).toMatchObject({ status: "complete", requests: 1 });
|
|
134
|
+
expect(report.results[1]).toMatchObject({ status: "incomplete", requests: 1, usage: null });
|
|
135
|
+
const retry = await judge(project.root, manifest, false, dependencies);
|
|
136
|
+
expect(retry.requests).toBe(0);
|
|
137
|
+
expect(uploads).toBe(2);
|
|
138
|
+
expect(retry.results[0]?.status).toBe("complete");
|
|
139
|
+
expect(retry.results[1]?.status).toBe("incomplete");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a later replay failure after upload preserves usage and earlier evidence", async () => {
|
|
143
|
+
using project = new TestProject();
|
|
144
|
+
const later = { ...project.item(), id: "later", requirement: `${project.item().requirement} Include the requested locale.` };
|
|
145
|
+
const prepared = await prepareItem(project.root, later);
|
|
146
|
+
const manifest = { stage: "verify" as const, items: [project.item(), later] };
|
|
147
|
+
let uploads = 0;
|
|
148
|
+
const dependencies = {
|
|
149
|
+
credential: "unused", scan: async () => {}, fetch: async () => {
|
|
150
|
+
if (++uploads === 2) mkdirSync(join(project.root, REPLAY_DIRECTORY, `${prepared.identity}.json`));
|
|
151
|
+
return Response.json(choiceReply());
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const report = await judge(project.root, manifest, true, dependencies);
|
|
155
|
+
expect(uploads).toBe(2);
|
|
156
|
+
expect(report.requests).toBe(2);
|
|
157
|
+
expect(report.results[0]).toMatchObject({ status: "complete", requests: 1 });
|
|
158
|
+
expect(report.results[1]).toMatchObject({ status: "incomplete", requests: 1, usage: choiceReply().usage, reason: "Replay file must be a regular file." });
|
|
159
|
+
expect(readdirSync(join(project.root, REPLAY_DIRECTORY)).filter((name) => name.startsWith(".pending-"))).toEqual([]);
|
|
160
|
+
const retry = await judge(project.root, manifest, false, dependencies);
|
|
161
|
+
expect(retry.requests).toBe(0);
|
|
162
|
+
expect(uploads).toBe(2);
|
|
163
|
+
expect(retry.results[0]?.status).toBe("complete");
|
|
164
|
+
expect(retry.results[1]?.status).toBe("incomplete");
|
|
165
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { CompletedItem, IncompleteItem, Item, Manifest, Prepared, Report } from "./types";
|
|
2
|
+
import type { Reply } from "./wire";
|
|
3
|
+
|
|
4
|
+
import { OrlyError } from "../model";
|
|
5
|
+
import { assess } from "./advice";
|
|
6
|
+
import { JUDGMENT_MODE, MODEL, RESULT_STATUS } from "./constants";
|
|
7
|
+
import { prepareItem } from "./evidence";
|
|
8
|
+
import { validateManifest } from "./questions";
|
|
9
|
+
import { readReplay, replayDestination, writeReplay } from "./replay";
|
|
10
|
+
import { scanUpload } from "./scan";
|
|
11
|
+
import { requestAdvice, type Fetch } from "./transport";
|
|
12
|
+
|
|
13
|
+
type Dependencies = { fetch: Fetch; scan: (request: string) => Promise<void>; credential: string | undefined };
|
|
14
|
+
type Preparation = { status: "prepared"; value: Prepared } | IncompleteItem;
|
|
15
|
+
const PREPARED_STATUS = "prepared";
|
|
16
|
+
const ENVIRONMENT = Object.freeze({ credential: Bun.env.TYPESAFE_API_KEY });
|
|
17
|
+
const PREFLIGHT_REASON = "Input preflight is incomplete; no live requests were made.";
|
|
18
|
+
|
|
19
|
+
export async function judge(root: string, manifest: Manifest, refresh: boolean, dependencies: Dependencies = {
|
|
20
|
+
fetch, scan: scanUpload, credential: ENVIRONMENT.credential,
|
|
21
|
+
}): Promise<Report> {
|
|
22
|
+
validateManifest(manifest, manifest.stage);
|
|
23
|
+
const prepared: Preparation[] = [];
|
|
24
|
+
for (const item of manifest.items) {
|
|
25
|
+
try { prepared.push({ status: PREPARED_STATUS, value: await prepareItem(root, item) }); }
|
|
26
|
+
catch (error) { prepared.push(incomplete(item, error)); }
|
|
27
|
+
}
|
|
28
|
+
if (refresh) {
|
|
29
|
+
if (prepared.some((item) => item.status === RESULT_STATUS.incomplete)) return failedPreflight(manifest, prepared);
|
|
30
|
+
const failure = await livePreflight(root, prepared, dependencies);
|
|
31
|
+
if (failure) return { stage: manifest.stage, advisory: true, requests: 0, results: manifest.items.map((item) => incomplete(item, failure)) };
|
|
32
|
+
}
|
|
33
|
+
const results: Report["results"] = [];
|
|
34
|
+
// MAX_ITEMS bounds this sequential loop. Each item permits at most one request and no retry.
|
|
35
|
+
for (const item of prepared) {
|
|
36
|
+
if (item.status === RESULT_STATUS.incomplete) results.push(item);
|
|
37
|
+
else results.push(await runItem(root, item.value, refresh, dependencies));
|
|
38
|
+
}
|
|
39
|
+
return { stage: manifest.stage, advisory: true, requests: results.reduce((total, item) => total + item.requests, 0), results };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function livePreflight(root: string, prepared: Preparation[], dependencies: Dependencies): Promise<OrlyError | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
if (!dependencies.credential?.trim()) throw new OrlyError("TYPESAFE_API_KEY is missing; nothing was uploaded.");
|
|
45
|
+
for (const item of prepared) if (item.status === PREPARED_STATUS) await dependencies.scan(item.value.request);
|
|
46
|
+
for (const item of prepared) if (item.status === PREPARED_STATUS) replayDestination(root, item.value);
|
|
47
|
+
return undefined;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return new OrlyError(safeReason(error));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runItem(root: string, prepared: Prepared, refresh: boolean, dependencies: Dependencies): Promise<CompletedItem | IncompleteItem> {
|
|
54
|
+
const started = performance.now();
|
|
55
|
+
let requests = 0;
|
|
56
|
+
let reply: Reply | undefined;
|
|
57
|
+
try {
|
|
58
|
+
if (!refresh) {
|
|
59
|
+
const cached = await readReplay(root, prepared);
|
|
60
|
+
return complete(prepared, cached.reply, cached.elapsedMs, JUDGMENT_MODE.replay);
|
|
61
|
+
}
|
|
62
|
+
const credential = dependencies.credential;
|
|
63
|
+
if (!credential) throw new OrlyError("Provider credential is unavailable.");
|
|
64
|
+
requests = 1;
|
|
65
|
+
reply = await requestAdvice(prepared, credential, dependencies.fetch);
|
|
66
|
+
const elapsedMs = performance.now() - started;
|
|
67
|
+
await writeReplay(root, prepared, reply, elapsedMs);
|
|
68
|
+
return complete(prepared, reply, elapsedMs, JUDGMENT_MODE.live);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { ...incomplete(prepared.item, error), requests, elapsedMs: performance.now() - started, usage: reply?.usage ?? null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function complete(prepared: Prepared, reply: Parameters<typeof assess>[1], elapsedMs: number, mode: "live" | "replay"): CompletedItem {
|
|
75
|
+
const answer = reply.answers.decision;
|
|
76
|
+
if (!answer) throw new OrlyError("Validated reply has no requested answer.");
|
|
77
|
+
return {
|
|
78
|
+
status: RESULT_STATUS.complete, id: prepared.item.id, question: prepared.item.question,
|
|
79
|
+
mode, model: MODEL, identity: prepared.identity, sources: prepared.references,
|
|
80
|
+
assessment: assess(prepared, reply), answer, requests: mode === JUDGMENT_MODE.live ? 1 : 0, elapsedMs, usage: reply.usage,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function incomplete(item: Item, error: unknown): IncompleteItem {
|
|
85
|
+
return { status: RESULT_STATUS.incomplete, id: item.id, question: item.question, reason: safeReason(error), requests: 0, elapsedMs: 0, usage: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeReason(error: unknown): string {
|
|
89
|
+
return error instanceof OrlyError ? error.message : "Judgment unavailable; raw source and provider details were not logged.";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function failedPreflight(manifest: Manifest, prepared: Preparation[]): Report {
|
|
93
|
+
return {
|
|
94
|
+
stage: manifest.stage, advisory: true, requests: 0,
|
|
95
|
+
results: prepared.map((item) => item.status === RESULT_STATUS.incomplete ? item : incomplete(item.value.item, new OrlyError(PREFLIGHT_REASON))),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { scanUpload } from "./scan";
|
|
4
|
+
|
|
5
|
+
test("real secret scanning accepts ordinary evidence", async () => {
|
|
6
|
+
await scanUpload(JSON.stringify({ state: "expect(actual).toBe(expected)" }));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("real secret scanning refuses a generated credential without logging it", async () => {
|
|
10
|
+
const fake = ["gh", "p_", Bun.randomUUIDv7().replaceAll("-", ""), "abcd"].join("");
|
|
11
|
+
const result = await scanUpload(JSON.stringify({ token: fake })).catch((error: unknown) => error);
|
|
12
|
+
expect(String(result)).toContain("found a credential");
|
|
13
|
+
expect(String(result)).not.toContain(fake);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("the secret scanner deadline terminates its owned process", async () => {
|
|
17
|
+
const result = await scanUpload("ordinary evidence", 0).catch((error: unknown) => error);
|
|
18
|
+
expect(String(result)).toContain("timed out");
|
|
19
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdtempSync, rmdirSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { OrlyError } from "../model";
|
|
6
|
+
import { IGNORE_OUTPUT, PIPE_OUTPUT, SCAN_TIMEOUT_MS } from "./constants";
|
|
7
|
+
|
|
8
|
+
const SCANNER = "gitleaks";
|
|
9
|
+
const SCAN_ARGUMENTS = ["stdin", "--redact", "--no-banner"];
|
|
10
|
+
const KILL_SIGNAL = "SIGKILL";
|
|
11
|
+
|
|
12
|
+
export async function scanUpload(request: string, timeoutMs = SCAN_TIMEOUT_MS): Promise<void> {
|
|
13
|
+
const directory = mkdtempSync(join(tmpdir(), "orly-judgment-scan-"));
|
|
14
|
+
let child: ReturnType<typeof Bun.spawn> | undefined;
|
|
15
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
16
|
+
let timedOut = false;
|
|
17
|
+
try {
|
|
18
|
+
// Empty scratch directory and a minimal environment prevent project scanner settings from hiding a secret.
|
|
19
|
+
child = Bun.spawn([SCANNER, ...SCAN_ARGUMENTS], {
|
|
20
|
+
cwd: directory, env: { PATH: Bun.env.PATH ?? "", LANG: "C" },
|
|
21
|
+
stdin: PIPE_OUTPUT, stdout: IGNORE_OUTPUT, stderr: IGNORE_OUTPUT,
|
|
22
|
+
});
|
|
23
|
+
const running = child;
|
|
24
|
+
timer = setTimeout(() => { timedOut = true; running.kill(KILL_SIGNAL); }, timeoutMs);
|
|
25
|
+
if (typeof child.stdin !== "object" || !("write" in child.stdin)) throw new OrlyError("Secret scanner input is unavailable.");
|
|
26
|
+
child.stdin.write(request);
|
|
27
|
+
child.stdin.end();
|
|
28
|
+
const code = await child.exited;
|
|
29
|
+
if (timedOut) throw new OrlyError("Secret scanning timed out; nothing was uploaded.");
|
|
30
|
+
if (code === 1) throw new OrlyError("Secret scanning found a credential in selected evidence; nothing was uploaded.");
|
|
31
|
+
if (code !== 0) throw new OrlyError("Secret scanner failed; nothing was uploaded.");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof OrlyError) throw error;
|
|
34
|
+
throw new OrlyError("Secret scanner is unavailable; install gitleaks before explicit refresh.");
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
if (child && child.exitCode === null) { child.kill(KILL_SIGNAL); await child.exited; }
|
|
38
|
+
rmdirSync(directory);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { selectSyntax } from "./syntax";
|
|
4
|
+
import { SOURCE, SOURCE_FILE, TEST_FILE, TEST_NAME, TEST_SOURCE, TestProject } from "./test_support";
|
|
5
|
+
|
|
6
|
+
test("selects a whole function with nested blocks and template literals", async () => {
|
|
7
|
+
const text = `const unrelated = 1;\nexport function target(value: string) {\n if (value) { return \`x\${value}\`; }\n return "";\n}\nconst after = 2;`;
|
|
8
|
+
const span = await selectSyntax(SOURCE_FILE, text, { kind: "function", name: "target" });
|
|
9
|
+
expect(text.slice(span.start, span.end)).toBe('export function target(value: string) {\n if (value) { return `x${value}`; }\n return "";\n}');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("selects complete named test calls", async () => {
|
|
13
|
+
const span = await selectSyntax(TEST_FILE, TEST_SOURCE, { kind: "test", name: TEST_NAME });
|
|
14
|
+
const selected = TEST_SOURCE.slice(span.start, span.end);
|
|
15
|
+
expect(selected.startsWith(`test("${TEST_NAME}"`)).toBe(true);
|
|
16
|
+
expect(selected.endsWith("})")).toBe(true);
|
|
17
|
+
expect(selected).toContain("toBeTruthy()");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("refuses duplicate symbols and skipped ancestor tests", async () => {
|
|
21
|
+
const duplicate = await selectSyntax(SOURCE_FILE, `${SOURCE}\n${SOURCE}`, { kind: "function", name: "label" }).catch((error: unknown) => error);
|
|
22
|
+
expect(duplicate).toBeInstanceOf(Error);
|
|
23
|
+
expect(String(duplicate)).toContain("Evidence selector must match one complete, executable unit.");
|
|
24
|
+
const skipped = `describe.skip("group", () => { ${TEST_SOURCE.slice(TEST_SOURCE.indexOf("test("))} });`;
|
|
25
|
+
const blocked = await selectSyntax(TEST_FILE, skipped, { kind: "test", name: TEST_NAME }).catch((error: unknown) => error);
|
|
26
|
+
expect(String(blocked)).toContain("Evidence selector must match one complete, executable unit.");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test.each(['test["skip"]', 'test["todo"]'])("refuses a statically computed skipped test: %s", async (callee) => {
|
|
30
|
+
const text = TEST_SOURCE.replace("test(", `${callee}(`);
|
|
31
|
+
await expect(selectSyntax(TEST_FILE, text, { kind: "test", name: TEST_NAME })).rejects.toThrow("Evidence selector must match one complete, executable unit.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.each(['describe["skip"]', 'describe["todo"]'])("refuses a statically computed skipped ancestor: %s", async (callee) => {
|
|
35
|
+
const text = `${callee}("group", () => { ${TEST_SOURCE.slice(TEST_SOURCE.indexOf("test("))} });`;
|
|
36
|
+
await expect(selectSyntax(TEST_FILE, text, { kind: "test", name: TEST_NAME })).rejects.toThrow("Evidence selector must match one complete, executable unit.");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("refuses syntax errors, absent symbols and unsupported file types", async () => {
|
|
40
|
+
const malformed = await selectSyntax(SOURCE_FILE, "function broken( {", { kind: "function", name: "broken" }).catch((error: unknown) => error);
|
|
41
|
+
expect(String(malformed)).toContain("Selected TypeScript has syntax errors.");
|
|
42
|
+
const absent = await selectSyntax(SOURCE_FILE, SOURCE, { kind: "function", name: "absent" }).catch((error: unknown) => error);
|
|
43
|
+
expect(String(absent)).toContain("Evidence selector must match one complete, executable unit.");
|
|
44
|
+
await expect(selectSyntax("source.rs", SOURCE, { kind: "function", name: "label" })).rejects.toThrow("Function and test selectors require TypeScript or JavaScript.");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("selects a section including its child headings and ignores fenced headings", async () => {
|
|
48
|
+
const text = "# Root\n## Selected\nProof\n### Child\nMore proof\n```md\n## Fake\n```\n## Next\nOther\n";
|
|
49
|
+
const span = await selectSyntax("spec.md", text, { kind: "section", heading: "Selected" });
|
|
50
|
+
expect(text.slice(span.start, span.end)).toBe("## Selected\nProof\n### Child\nMore proof\n```md\n## Fake\n```\n");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test.each(["```", "~~~"])("a fenced example can repeat a real heading with fence %s", async (fence) => {
|
|
54
|
+
const selected = `## Selected\nProof\n${fence}md\n## Selected\n${fence}\n`;
|
|
55
|
+
const text = `${selected}## Next\nOther\n`;
|
|
56
|
+
const span = await selectSyntax("spec.md", text, { kind: "section", heading: "Selected" });
|
|
57
|
+
expect(text.slice(span.start, span.end)).toBe(selected);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("shorter and unterminated fences cannot introduce section headings", async () => {
|
|
61
|
+
const text = "## Selected\nProof\n````md\n## Selected\n```\n## Selected\n";
|
|
62
|
+
const span = await selectSyntax("spec.md", text, { kind: "section", heading: "Selected" });
|
|
63
|
+
expect(text.slice(span.start, span.end)).toBe(text);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a list-item fence can repeat a real section heading", async () => {
|
|
67
|
+
const selected = "## Selected\nText\n- ```md\n ## Selected\n ```\n\n";
|
|
68
|
+
const text = `${selected}## Next\nOther\n`;
|
|
69
|
+
const span = await selectSyntax("spec.md", text, { kind: "section", heading: "Selected" });
|
|
70
|
+
expect(text.slice(span.start, span.end)).toBe(selected);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("literal trailing hashes remain part of a Markdown heading", async () => {
|
|
74
|
+
const text = "## C#\nProof\n## Next\nOther\n";
|
|
75
|
+
const span = await selectSyntax("spec.md", text, { kind: "section", heading: "C#" });
|
|
76
|
+
expect(text.slice(span.start, span.end)).toBe("## C#\nProof\n");
|
|
77
|
+
await expect(selectSyntax("spec.md", text, { kind: "section", heading: "C" })).rejects.toThrow("Markdown section must match one complete heading.");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("whitespace-separated closing hashes do not become part of the heading", async () => {
|
|
81
|
+
const text = "## C# ##\nProof\n## Next\nOther\n";
|
|
82
|
+
const span = await selectSyntax("spec.md", text, { kind: "section", heading: "C#" });
|
|
83
|
+
expect(text.slice(span.start, span.end)).toBe("## C# ##\nProof\n");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("refuses duplicate or missing Markdown headings", async () => {
|
|
87
|
+
await expect(selectSyntax("spec.md", "## Repeat\none\n## Repeat\ntwo", { kind: "section", heading: "Repeat" })).rejects.toThrow("Markdown section requires an unambiguous plain heading.");
|
|
88
|
+
await expect(selectSyntax("spec.md", "## Other\ntext", { kind: "section", heading: "Missing" })).rejects.toThrow("Markdown section must match one complete heading.");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("selects a typed function containing JSX without running its source", async () => {
|
|
92
|
+
const text = 'export const label = (value: string) => <span>{value}</span>;\nthrow new Error("source must never run");';
|
|
93
|
+
const span = await selectSyntax("label.tsx", text, { kind: "function", name: "label" });
|
|
94
|
+
expect(text.slice(span.start, span.end)).toBe('label = (value: string) => <span>{value}</span>');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("parser timeout ends its worker and a later parse remains usable", async () => {
|
|
98
|
+
using project = new TestProject();
|
|
99
|
+
const waiting = "waiting-worker.ts";
|
|
100
|
+
project.write(waiting, "self.onmessage = () => {}; ");
|
|
101
|
+
const error = await selectSyntax(SOURCE_FILE, SOURCE, { kind: "function", name: "label" }, {
|
|
102
|
+
timeoutMs: 10, workerUrl: new URL(`file://${project.root}/${waiting}`),
|
|
103
|
+
}).catch((reason: unknown) => reason);
|
|
104
|
+
expect(String(error)).toContain("TypeScript evidence parsing timed out.");
|
|
105
|
+
const span = await selectSyntax(SOURCE_FILE, SOURCE, { kind: "function", name: "label" });
|
|
106
|
+
expect(SOURCE.slice(span.start, span.end)).toBe(SOURCE);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("parser startup failure refuses evidence without exposing raw worker details", async () => {
|
|
110
|
+
const error = await selectSyntax(SOURCE_FILE, SOURCE, { kind: "function", name: "label" }, {
|
|
111
|
+
workerUrl: new URL("./missing-parser-worker.ts", import.meta.url),
|
|
112
|
+
}).catch((reason: unknown) => reason);
|
|
113
|
+
expect(String(error)).toContain("TypeScript evidence could not be parsed.");
|
|
114
|
+
expect(String(error)).not.toContain("missing-parser-worker");
|
|
115
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Selector } from "./types";
|
|
2
|
+
import type { ParserReply } from "./syntax_worker";
|
|
3
|
+
|
|
4
|
+
import { extname } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { OrlyError } from "../model";
|
|
7
|
+
import { PARSE_TIMEOUT_MS } from "./constants";
|
|
8
|
+
|
|
9
|
+
type Span = { start: number; end: number };
|
|
10
|
+
type LocatedHeading = { title: string; level: number; start: number };
|
|
11
|
+
const TYPESCRIPT_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"]);
|
|
12
|
+
const SECTION_HEADING_ERROR = "Markdown section requires an unambiguous plain heading.";
|
|
13
|
+
type ParserOptions = { timeoutMs?: number; workerUrl?: URL };
|
|
14
|
+
|
|
15
|
+
export async function selectSyntax(path: string, text: string, selector: Selector, options: ParserOptions = {}): Promise<Span> {
|
|
16
|
+
if (selector.kind === "file") return { start: 0, end: text.length };
|
|
17
|
+
if (selector.kind === "section") return selectSection(text, selector.heading);
|
|
18
|
+
if (!TYPESCRIPT_EXTENSIONS.has(extname(path))) throw new OrlyError("Function and test selectors require TypeScript or JavaScript.");
|
|
19
|
+
const worker = new Worker(options.workerUrl ?? new URL("./syntax_worker.ts", import.meta.url));
|
|
20
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
21
|
+
try {
|
|
22
|
+
return await new Promise<Span>((resolve, reject) => {
|
|
23
|
+
timer = setTimeout(() => reject(new OrlyError("TypeScript evidence parsing timed out.")), options.timeoutMs ?? PARSE_TIMEOUT_MS);
|
|
24
|
+
worker.onmessage = (event: MessageEvent<ParserReply>) => {
|
|
25
|
+
const reply = event.data;
|
|
26
|
+
if (!reply.ok) reject(new OrlyError(reply.reason));
|
|
27
|
+
else resolve({ start: reply.start, end: reply.end });
|
|
28
|
+
};
|
|
29
|
+
worker.onerror = (event) => { event.preventDefault(); reject(new OrlyError("TypeScript evidence could not be parsed.")); };
|
|
30
|
+
worker.postMessage({ path, text, selector });
|
|
31
|
+
});
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
worker.terminate();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function selectSection(text: string, heading: string): Span {
|
|
39
|
+
const headings = sectionHeadings(text);
|
|
40
|
+
const indices = headings.flatMap((entry, index) => entry.title === heading ? [index] : []);
|
|
41
|
+
if (indices.length !== 1) throw new OrlyError("Markdown section must match one complete heading.");
|
|
42
|
+
const index = indices[0];
|
|
43
|
+
const selected = index === undefined ? undefined : headings[index];
|
|
44
|
+
if (!selected || index === undefined) throw new OrlyError("Markdown section is unavailable.");
|
|
45
|
+
const next = headings.slice(index + 1).find((entry) => entry.level <= selected.level);
|
|
46
|
+
return { start: selected.start, end: next?.start ?? text.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sectionHeadings(text: string): LocatedHeading[] {
|
|
50
|
+
const headings: LocatedHeading[] = [];
|
|
51
|
+
const positions = new Map<string, LocatedHeading>();
|
|
52
|
+
const seen = new Set<string>();
|
|
53
|
+
const nonce = Bun.randomUUIDv7();
|
|
54
|
+
// Mark candidate headings in a parsing copy; Bun decides which belong to real sections.
|
|
55
|
+
const marked = text.replace(/^( {0,3})(#{1,6})[\t ]+([^\r\n]+?)[\t ]*$/gm,
|
|
56
|
+
(_line: string, indentation: string, hashes: string, rawTitle: string, offset: number) => {
|
|
57
|
+
const title = rawTitle.replace(/[\t ]+#+$/, "");
|
|
58
|
+
const key = `orly-${nonce}-${offset} ${title}`;
|
|
59
|
+
positions.set(key, { title, level: hashes.length, start: offset });
|
|
60
|
+
return `${indentation}${hashes} ${key}`;
|
|
61
|
+
});
|
|
62
|
+
Bun.markdown.render(marked, { heading: (title, { level }) => {
|
|
63
|
+
const found = positions.get(title);
|
|
64
|
+
const identity = `${level}:${found?.title}`;
|
|
65
|
+
if (!found || found.level !== level || seen.has(identity)) throw new OrlyError(SECTION_HEADING_ERROR);
|
|
66
|
+
seen.add(identity);
|
|
67
|
+
headings.push(found);
|
|
68
|
+
return title;
|
|
69
|
+
} });
|
|
70
|
+
return headings;
|
|
71
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Selector } from "./types";
|
|
2
|
+
|
|
3
|
+
import { parse, type ParserPlugin } from "@babel/parser";
|
|
4
|
+
import { isArrowFunctionExpression, isCallExpression, isExportDefaultDeclaration, isExportNamedDeclaration, isFunctionDeclaration, isFunctionExpression, isIdentifier, isMemberExpression, isStringLiteral, isVariableDeclarator, traverse, type Node, type TraversalAncestors } from "@babel/types";
|
|
5
|
+
import { extname } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { SELECTOR_KIND } from "./constants";
|
|
8
|
+
|
|
9
|
+
export type ParserReply = { ok: true; start: number; end: number } | { ok: false; reason: string };
|
|
10
|
+
type Request = { path: string; text: string; selector: Extract<Selector, { kind: "function" | "test" }> };
|
|
11
|
+
type Span = { start: number; end: number };
|
|
12
|
+
const TEST_FUNCTIONS = new Set(["it", SELECTOR_KIND.test]);
|
|
13
|
+
const SKIPPED_TESTS = new Set(["skip", "todo"]);
|
|
14
|
+
const TYPESCRIPT_JSX_EXTENSION = ".tsx";
|
|
15
|
+
const JSX_EXTENSIONS = new Set([TYPESCRIPT_JSX_EXTENSION, ".jsx"]);
|
|
16
|
+
const TYPESCRIPT_EXTENSIONS = new Set([".ts", TYPESCRIPT_JSX_EXTENSION, ".mts", ".cts"]);
|
|
17
|
+
const SELECTOR_ERROR = "Evidence selector must match one complete, executable unit.";
|
|
18
|
+
const SYNTAX_ERROR = "Selected TypeScript has syntax errors.";
|
|
19
|
+
declare const self: Worker;
|
|
20
|
+
|
|
21
|
+
self.onmessage = (event: MessageEvent<Request>) => {
|
|
22
|
+
try { self.postMessage({ ok: true, ...selectProgramSpan(event.data) } satisfies ParserReply); }
|
|
23
|
+
catch (error) { self.postMessage({ ok: false, reason: error instanceof Error && error.message === SELECTOR_ERROR ? SELECTOR_ERROR : SYNTAX_ERROR } satisfies ParserReply); }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function selectProgramSpan({ path, text, selector }: Request): Span {
|
|
27
|
+
const extension = extname(path);
|
|
28
|
+
const plugins: ParserPlugin[] = [];
|
|
29
|
+
if (TYPESCRIPT_EXTENSIONS.has(extension)) plugins.push("typescript");
|
|
30
|
+
if (JSX_EXTENSIONS.has(extension)) plugins.push("jsx");
|
|
31
|
+
const tree = parse(text, { sourceType: "unambiguous", plugins, errorRecovery: true, attachComment: false });
|
|
32
|
+
const matches: Span[] = [];
|
|
33
|
+
traverse(tree, (node, ancestors) => {
|
|
34
|
+
if (ancestors.some(({ node: ancestor }) => isSkipped(ancestor)) || isSkipped(node) || !matchesSelector(node, selector)) return;
|
|
35
|
+
const selected = declarationSpan(node, ancestors);
|
|
36
|
+
if (selected.start !== null && selected.start !== undefined && selected.end !== null && selected.end !== undefined) {
|
|
37
|
+
matches.push({ start: selected.start, end: selected.end });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const match = matches[0];
|
|
41
|
+
if (matches.length !== 1 || !match) throw new Error(SELECTOR_ERROR);
|
|
42
|
+
if (tree.errors?.length) throw new Error(SYNTAX_ERROR);
|
|
43
|
+
return match;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function declarationSpan(node: Node, ancestors: TraversalAncestors): Node {
|
|
47
|
+
const parent = ancestors.at(-1)?.node;
|
|
48
|
+
return parent && (isExportNamedDeclaration(parent) || isExportDefaultDeclaration(parent)) ? parent : node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isSkipped(node: Node): boolean {
|
|
52
|
+
if (!isCallExpression(node) || !isMemberExpression(node.callee)) return false;
|
|
53
|
+
const { computed, property } = node.callee;
|
|
54
|
+
const name = computed && isStringLiteral(property) ? property.value : !computed && isIdentifier(property) ? property.name : undefined;
|
|
55
|
+
return name !== undefined && SKIPPED_TESTS.has(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesSelector(node: Node, selector: Request["selector"]): boolean {
|
|
59
|
+
if (selector.kind === SELECTOR_KIND.function) {
|
|
60
|
+
if (isFunctionDeclaration(node)) return node.id?.name === selector.name && node.body !== undefined;
|
|
61
|
+
return isVariableDeclarator(node) && isIdentifier(node.id) && node.id.name === selector.name
|
|
62
|
+
&& (isArrowFunctionExpression(node.init) || isFunctionExpression(node.init));
|
|
63
|
+
}
|
|
64
|
+
if (!isCallExpression(node)) return false;
|
|
65
|
+
const callee = isMemberExpression(node.callee) ? node.callee.object : node.callee;
|
|
66
|
+
const label = node.arguments[0];
|
|
67
|
+
const callback = node.arguments[1];
|
|
68
|
+
return isIdentifier(callee) && TEST_FUNCTIONS.has(callee.name) && isStringLiteral(label)
|
|
69
|
+
&& label.value === selector.name && (isArrowFunctionExpression(callback) || isFunctionExpression(callback));
|
|
70
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Item, Manifest, Prepared } from "./types";
|
|
2
|
+
import type { Reply } from "./wire";
|
|
3
|
+
|
|
4
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { CHOICE, MODEL, REPLY_KEY, ROLE, SELECTOR_KIND } from "./constants";
|
|
9
|
+
import { prepareItem } from "./evidence";
|
|
10
|
+
|
|
11
|
+
export const SOURCE_FILE = "label.ts";
|
|
12
|
+
export const TEST_FILE = "label.test.ts";
|
|
13
|
+
export const TEST_NAME = "label respects locale";
|
|
14
|
+
export const REQUIREMENT = "label returns the requested locale's year, month and day from Intl.DateTimeFormat, rather than merely a nonempty string.";
|
|
15
|
+
export const SOURCE = `export function label(value: string, locale: string): string {
|
|
16
|
+
return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "2-digit" }).format(new Date(value));
|
|
17
|
+
}`;
|
|
18
|
+
export const TEST_SOURCE = `import { test, expect } from "bun:test";
|
|
19
|
+
import { label } from "./label";
|
|
20
|
+
test("${TEST_NAME}", () => {
|
|
21
|
+
expect(label("2026-10-04T00:00:00Z", "en-GB")).toBeTruthy();
|
|
22
|
+
});`;
|
|
23
|
+
|
|
24
|
+
export class TestProject {
|
|
25
|
+
readonly #root = mkdtempSync(join(tmpdir(), "orly-judgments-test-"));
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
this.write(SOURCE_FILE, SOURCE);
|
|
29
|
+
this.write(TEST_FILE, TEST_SOURCE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get root(): string { return this.#root; }
|
|
33
|
+
|
|
34
|
+
write(path: string, text: string): void { writeFileSync(join(this.#root, path), text); }
|
|
35
|
+
|
|
36
|
+
item(): Item {
|
|
37
|
+
return {
|
|
38
|
+
id: "locale", question: "verify.assertion", requirement: REQUIREMENT,
|
|
39
|
+
evidence: [
|
|
40
|
+
{ role: "implementation", path: SOURCE_FILE, selector: { kind: "function", name: "label" } },
|
|
41
|
+
{ role: ROLE.test, path: TEST_FILE, selector: { kind: SELECTOR_KIND.test, name: TEST_NAME } },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
manifest(): Manifest { return { stage: "verify", items: [this.item()] }; }
|
|
47
|
+
|
|
48
|
+
prepare(): Promise<Prepared> { return prepareItem(this.#root, this.item()); }
|
|
49
|
+
|
|
50
|
+
[Symbol.dispose](): void { rmSync(this.#root, { recursive: true, force: true }); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function choiceReply(choice: string = CHOICE.weak): Reply {
|
|
54
|
+
const probabilities = Object.fromEntries(Object.values(CHOICE).map((option) => [option, option === choice ? 1 : 0]));
|
|
55
|
+
return { model: MODEL, answers: { [REPLY_KEY]: { type: "choice", choice, probabilities, confidence: 1 } }, usage: { input_tokens: 100, output_tokens: 20 } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function noulReply(probability: number): Reply {
|
|
59
|
+
return { model: MODEL, answers: { [REPLY_KEY]: { type: "noul", noul: probability } }, usage: { input_tokens: 100, output_tokens: 20 } };
|
|
60
|
+
}
|