@agentsfleet/orly 0.10.13 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-push +2 -1
- package/AGENTS.md +21 -31
- package/README.md +65 -29
- package/SOUL.md +13 -23
- package/audits/agents-md.md +67 -57
- package/audits/deinit-pairs.sh +13 -18
- package/audits/design-tokens.sh +9 -23
- package/audits/doc-read.sh +10 -33
- package/audits/doc-reads.ts +41 -0
- package/audits/error-codes.sh +18 -25
- package/audits/logging-rust.awk +145 -0
- package/audits/logging.sh +10 -166
- package/audits/msid-ui.sh +45 -18
- package/audits/rule-ledger-lib.sh +9 -6
- package/audits/rust-error.sh +20 -15
- package/audits/scope.sh +88 -0
- package/audits/spec-template.sh +2 -2
- package/audits/spec-template.ts +1 -1
- package/audits/sql-mod.sh +11 -14
- package/audits/ufs-strings.awk +164 -0
- package/audits/ufs.sh +10 -188
- package/core/operating-model.md +8 -8
- package/dispatch/edit_rules.md +8 -6
- package/dispatch/lib.sh +41 -26
- package/dispatch/lifecycle.md +18 -6
- package/dispatch/name_architecture.md +8 -3
- package/dispatch/verify.md +1 -1
- package/dispatch/write_any.md +10 -3
- package/dispatch/write_changelog.md +7 -4
- package/dispatch/write_go.md +1 -1
- package/dispatch/write_http.md +5 -7
- package/dispatch/write_python.md +1 -1
- package/dispatch/write_rust.md +17 -1
- package/dispatch/write_shell.md +1 -1
- package/dispatch/write_spec.md +3 -1
- package/dispatch/write_sql.md +28 -6
- package/dispatch/write_ts_adhere_bun.md +11 -0
- package/dispatch/write_zig.md +53 -21
- package/docs/CHANGELOG_VOICE.md +11 -2
- package/docs/EXECUTE_DOC_READS.md +10 -10
- package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
- package/docs/JUDGMENTS.md +153 -0
- package/docs/LIFECYCLE_PATTERNS.md +26 -16
- package/docs/LOGGING_STANDARD.md +39 -19
- package/docs/ORLY_ARCHITECTURE.md +77 -6
- package/docs/RELEASE_TEMPLATE.md +21 -5
- package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
- package/docs/RULE_ENFORCEMENT.md +6 -6
- package/docs/SCHEMA_CONVENTIONS.md +24 -3
- package/docs/TEMPLATE.md +4 -4
- package/docs/VERIFY_TIERS.md +2 -2
- package/docs/greptile-learnings/RULES.md +81 -26
- package/llms.txt +37 -34
- package/package.json +6 -1
- package/packs/language/go/rules.md +1 -1
- package/packs/language/python/rules.md +1 -1
- package/packs/language/rust/rules.md +17 -1
- package/packs/language/shell/rules.md +1 -1
- package/registry.json +22 -7
- package/schemas/profile.schema.json +48 -5
- package/skills/orly-babysit-prs/SKILL.md +87 -34
- package/skills/orly-spec-new/SKILL.md +19 -6
- package/skills/orly-write-integration-test/SKILL.md +4 -4
- package/skills/orly-write-unit-test/SKILL.md +10 -1
- package/src/cli.test.ts +2 -2
- package/src/cli.ts +19 -75
- package/src/cli_help.ts +69 -0
- package/src/command_limits.ts +20 -0
- package/src/command_process.test.ts +109 -0
- package/src/command_process.ts +88 -0
- package/src/command_runner.test.ts +88 -0
- package/src/command_runner.ts +33 -0
- package/src/comprehension_cache.test.ts +123 -0
- package/src/config.test.ts +5 -4
- package/src/config.ts +26 -164
- package/src/config_discovery.ts +159 -0
- package/src/config_validation.test.ts +56 -0
- package/src/config_validation.ts +25 -0
- package/src/criteria.test.ts +1 -1
- package/src/criteria.ts +10 -26
- package/src/criteria_spec.ts +12 -20
- package/src/criteria_support.ts +2 -19
- package/src/doc_reads.test.ts +42 -0
- package/src/doc_rules.ts +2 -2
- package/src/evaluation_resources.test.ts +64 -0
- package/src/execution/config.ts +22 -0
- package/src/execution/plan.test.ts +65 -0
- package/src/execution/plan.ts +51 -0
- package/src/gates.ts +39 -24
- package/src/gates_spec.test.ts +110 -4
- package/src/gates_test_support.ts +9 -7
- package/src/install.test.ts +38 -222
- package/src/install.ts +117 -308
- package/src/install_loaders.test.ts +203 -0
- package/src/install_nested.test.ts +2 -2
- package/src/install_packs.test.ts +66 -6
- package/src/installation/hooks.ts +63 -0
- package/src/installation/lock.test.ts +97 -0
- package/src/installation/lock.ts +56 -0
- package/src/installation/migration.test.ts +212 -0
- package/src/installation/migration.ts +65 -0
- package/src/installation/plan.ts +96 -0
- package/src/installation/transaction.ts +126 -0
- package/src/installation_preservation.test.ts +48 -0
- package/src/judgments/advice.test.ts +38 -0
- package/src/judgments/advice.ts +32 -0
- package/src/judgments/command.integration.test.ts +47 -0
- package/src/judgments/command.ts +90 -0
- package/src/judgments/constants.ts +55 -0
- package/src/judgments/evidence.test.ts +48 -0
- package/src/judgments/evidence.ts +34 -0
- package/src/judgments/files.test.ts +62 -0
- package/src/judgments/files.ts +73 -0
- package/src/judgments/questions.test.ts +19 -0
- package/src/judgments/questions.ts +85 -0
- package/src/judgments/replay.test.ts +43 -0
- package/src/judgments/replay.ts +66 -0
- package/src/judgments/runner.integration.test.ts +165 -0
- package/src/judgments/runner.ts +97 -0
- package/src/judgments/scan.test.ts +19 -0
- package/src/judgments/scan.ts +40 -0
- package/src/judgments/syntax.test.ts +115 -0
- package/src/judgments/syntax.ts +71 -0
- package/src/judgments/syntax_worker.ts +70 -0
- package/src/judgments/test_support.ts +60 -0
- package/src/judgments/transport.integration.test.ts +63 -0
- package/src/judgments/transport.ts +35 -0
- package/src/judgments/types.test.ts +43 -0
- package/src/judgments/types.ts +66 -0
- package/src/judgments/wire.test.ts +48 -0
- package/src/judgments/wire.ts +40 -0
- package/src/loaders.test.ts +13 -16
- package/src/loaders.ts +28 -10
- package/src/model.test.ts +20 -3
- package/src/model.ts +7 -18
- package/src/pack_hygiene.test.ts +42 -1
- package/src/pack_hygiene.ts +18 -8
- package/src/references.ts +15 -7
- package/src/release_inventory.test.ts +80 -0
- package/src/render.test.ts +1 -1
- package/src/render.ts +3 -8
- package/src/required_scanner.test.ts +36 -0
- package/src/review_poll.test.ts +158 -0
- package/src/scanner_scope.test.ts +202 -0
- package/src/setup.test.ts +9 -6
- package/src/spec_baseline.test.ts +1 -1
- package/src/spec_completion.test.ts +74 -0
- package/src/spec_obligations.ts +64 -0
- package/src/spec_rehearsal.test.ts +182 -0
- package/src/spec_template_io.test.ts +11 -9
- package/src/surfaces.test.ts +2 -0
- package/src/surfaces.ts +1 -1
- package/src/validation.ts +7 -4
- package/src/verify.test.ts +18 -2
- package/src/verify.ts +7 -3
package/src/setup.test.ts
CHANGED
|
@@ -78,11 +78,14 @@ describe("documentation repositories", () => {
|
|
|
78
78
|
|
|
79
79
|
// Use a fresh executable directory, as a global package installation would.
|
|
80
80
|
const binaries = temporaryDirectory();
|
|
81
|
-
|
|
81
|
+
// Hermetic hook routing; the packed evaluation separately runs real bunx.
|
|
82
|
+
const launcher = join(binaries, "bunx");
|
|
83
|
+
await Bun.write(launcher, '#!/bin/sh\nset -eu\ntest "$1" = "--bun"\ncase "$2" in @agentsfleet/orly@*) ;; *) exit 9 ;; esac\nshift 2\nexec "$ORLY_TEST_ENTRY" "$@"\n');
|
|
84
|
+
chmodSync(launcher, 0o755);
|
|
82
85
|
symlinkSync(process.execPath, join(binaries, "bun"));
|
|
83
86
|
git(root, "add", ".");
|
|
84
87
|
const commit = Bun.spawnSync(["/usr/bin/git", "commit", "-qm", "docs: initialize checks"], {
|
|
85
|
-
cwd: root, env: { ...UNSCOPED_ENVIRONMENT, PATH: `${binaries}:/usr/bin:/bin`, ORLY_TELEMETRY_OFF: "1" }, stdout: "pipe", stderr: "pipe",
|
|
88
|
+
cwd: root, env: { ...UNSCOPED_ENVIRONMENT, PATH: `${binaries}:/usr/bin:/bin`, ORLY_TELEMETRY_OFF: "1", ORLY_TEST_ENTRY: join(ROOT, "bin/orly") }, stdout: "pipe", stderr: "pipe",
|
|
86
89
|
});
|
|
87
90
|
expect({ code: commit.exitCode, output: commit.stderr.toString() }).toEqual({ code: 0, output: expect.any(String) });
|
|
88
91
|
|
|
@@ -96,7 +99,7 @@ describe("documentation repositories", () => {
|
|
|
96
99
|
}
|
|
97
100
|
});
|
|
98
101
|
|
|
99
|
-
test("
|
|
102
|
+
test("missing bunx fails the hook without running a different installer", async () => {
|
|
100
103
|
const root = newRepository();
|
|
101
104
|
expect(orly(root, ROOT, "init").code).toBe(0);
|
|
102
105
|
const binaries = temporaryDirectory();
|
|
@@ -106,11 +109,11 @@ test("a missing executable prints installation guidance without running an insta
|
|
|
106
109
|
chmodSync(installer, 0o755);
|
|
107
110
|
|
|
108
111
|
for (const hook of ["pre-commit", "pre-push"]) {
|
|
109
|
-
const result = Bun.spawnSync(["/bin/bash", `.
|
|
112
|
+
const result = Bun.spawnSync(["/bin/bash", `.orly/hooks/${hook}`], {
|
|
110
113
|
cwd: root, env: { ...process.env, PATH: binaries, ORLY_TEST_MARKER: marker }, stdout: "pipe", stderr: "pipe",
|
|
111
114
|
});
|
|
112
|
-
expect(result.exitCode).toBe(
|
|
113
|
-
expect(result.stderr.toString()).toContain("
|
|
115
|
+
expect(result.exitCode).toBe(127);
|
|
116
|
+
expect(result.stderr.toString()).toContain("bunx");
|
|
114
117
|
expect(existsSync(marker)).toBe(false);
|
|
115
118
|
}
|
|
116
119
|
});
|
|
@@ -5,7 +5,7 @@ import type { CriterionContext } from "./criteria_support";
|
|
|
5
5
|
import { specBaseline } from "./criteria_spec";
|
|
6
6
|
import { cleanupTemporaryDirectories, git, gitOutput, modelFor, newRepository } from "./gates_test_support";
|
|
7
7
|
|
|
8
|
-
const CONFIG_PATH = ".
|
|
8
|
+
const CONFIG_PATH = ".orly/orly.json";
|
|
9
9
|
const UNIT = "**Test Baseline:** unit=12 integration=4";
|
|
10
10
|
const EVIDENCE = "**Baseline evidence:** README.md";
|
|
11
11
|
const NO_CODE = "**Test Baseline:** n/a — no code on this branch";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { runGate } from "./gates";
|
|
6
|
+
import { cleanupTemporaryDirectories, modelFor, newSpecRepository, SPEC_RELATIVE } from "./gates_test_support";
|
|
7
|
+
|
|
8
|
+
const DIMENSION_LINE = "- **Dimension 1.1** — DONE — fixture behaviour → Test `fixture_test`";
|
|
9
|
+
const DIMENSIONS = "spec.dimensions";
|
|
10
|
+
const DEFERRALS = "spec.deferrals";
|
|
11
|
+
const ACK_PREFIX = '> Indy (Oct 05, 2026: 02:30 PM): "';
|
|
12
|
+
const ACK_SUFFIX = '" — context: the named deferred item.';
|
|
13
|
+
|
|
14
|
+
afterEach(cleanupTemporaryDirectories);
|
|
15
|
+
|
|
16
|
+
async function verdict(rewrite: (source: string) => string, criterion: string) {
|
|
17
|
+
const root = newSpecRepository();
|
|
18
|
+
const model = await modelFor(root);
|
|
19
|
+
const path = join(root, SPEC_RELATIVE);
|
|
20
|
+
writeFileSync(path, rewrite(readFileSync(path, "utf8")));
|
|
21
|
+
return runGate(model, root, "pr").results.find((result) => result.name === criterion)!;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const marker of ["NOT_DONE", "UNDONE", "DONE_LATER", "IN_PROGRESS — DONE mentioned in the explanation"]) {
|
|
25
|
+
test(`a ${marker} Dimension remains incomplete`, async () => {
|
|
26
|
+
const result = await verdict((source) => source.replace(DIMENSION_LINE, DIMENSION_LINE.replace(" — DONE —", ` — ${marker} —`)), DIMENSIONS);
|
|
27
|
+
expect(result.ok).toBeFalse();
|
|
28
|
+
expect(result.detail).toContain("1.1");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("example and quoted Dimension markers cannot supply completion", async () => {
|
|
33
|
+
const result = await verdict((source) => source.replace(DIMENSION_LINE, `\`\`\`markdown\n${DIMENSION_LINE}\n\`\`\`\n\n> ${DIMENSION_LINE}`), DIMENSIONS);
|
|
34
|
+
expect(result.ok).toBeFalse();
|
|
35
|
+
expect(result.detail).toContain("no Dimensions");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an exact DONE marker completes the declared Dimension", async () => {
|
|
39
|
+
expect((await verdict((source) => source, DIMENSIONS)).ok).toBeTrue();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function withClaim(source: string, quote: string): string {
|
|
43
|
+
return source.replace("Fixture content for Discovery (consult log).", `- Dimension 1.2 was deferred to follow-up.\n\n${quote}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("an unrelated owner quote cannot acknowledge another Dimension", async () => {
|
|
47
|
+
const result = await verdict((source) => withClaim(source, `${ACK_PREFIX}defer 9.2${ACK_SUFFIX}`), DEFERRALS);
|
|
48
|
+
expect(result.ok).toBeFalse();
|
|
49
|
+
expect(result.detail).toContain("Dimension 1.2");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a quote in a code example cannot acknowledge a deferral", async () => {
|
|
53
|
+
const result = await verdict((source) => withClaim(source, `\`\`\`markdown\n${ACK_PREFIX}defer 1.2${ACK_SUFFIX}\n\`\`\``), DEFERRALS);
|
|
54
|
+
expect(result.ok).toBeFalse();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a quote outside Discovery cannot acknowledge a deferral", async () => {
|
|
58
|
+
const result = await verdict((source) => withClaim(source, "").replace("Fixture content for Overview.", `${ACK_PREFIX}defer 1.2${ACK_SUFFIX}`), DEFERRALS);
|
|
59
|
+
expect(result.ok).toBeFalse();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("an item-bound owner quote acknowledges only the named deferral", async () => {
|
|
63
|
+
const quote = `${ACK_PREFIX}defer 1.2, ship the rest${ACK_SUFFIX}`;
|
|
64
|
+
expect((await verdict((source) => withClaim(source, quote), DEFERRALS)).ok).toBeTrue();
|
|
65
|
+
const result = await verdict((source) => withClaim(source, `${quote}\n\n- Dimension 1.3 was deferred to follow-up.`), DEFERRALS);
|
|
66
|
+
expect(result.ok).toBeFalse();
|
|
67
|
+
expect(result.detail).toContain("Dimension 1.3");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an unnamed deferral must name its obligation before acknowledgement", async () => {
|
|
71
|
+
const result = await verdict((source) => source.replace("Fixture content for Discovery (consult log).", "- Performance proof was deferred to follow-up."), DEFERRALS);
|
|
72
|
+
expect(result.ok).toBeFalse();
|
|
73
|
+
expect(result.detail).toContain("unnamed obligation");
|
|
74
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
type Node = string | { type?: unknown; props?: { children?: Node[] } };
|
|
2
|
+
type Section = { title: string; nodes: Node[] };
|
|
3
|
+
type Dimension = { id: string; done: boolean };
|
|
4
|
+
|
|
5
|
+
const DIMENSION = /^Dimension (\d+\.\d+)\s*[—-]\s*/;
|
|
6
|
+
const COMPLETED = /^DONE(?:\s*[—-]|$)/;
|
|
7
|
+
const CLAIM = /\bdeferr(ed|al|als)\b|\bMOVED\s+to\s+M\d+_\d+|\btransferred\b/i;
|
|
8
|
+
const ACK = /^Indy \([^\n()]+\): "([^"\n]+)"\s*[—-]\s*context:\s*\S/;
|
|
9
|
+
const BLOCKQUOTE = "blockquote";
|
|
10
|
+
const LIST_ITEM = "li";
|
|
11
|
+
|
|
12
|
+
function isElement(node: Node): node is Exclude<Node, string> { return typeof node !== "string"; }
|
|
13
|
+
function children(node: Node): Node[] { return isElement(node) ? node.props?.children ?? [] : []; }
|
|
14
|
+
function text(node: Node): string { return isElement(node) ? children(node).map(text).join("") : node; }
|
|
15
|
+
|
|
16
|
+
function sectionsOf(source: string): Section[] {
|
|
17
|
+
const sections: Section[] = [{ title: "", nodes: [] }];
|
|
18
|
+
for (const node of children(Bun.markdown.react(source) as Node)) {
|
|
19
|
+
if (isElement(node) && node.type === "h2") sections.push({ title: text(node), nodes: [] });
|
|
20
|
+
else if (sections.length) sections[sections.length - 1]!.nodes.push(node);
|
|
21
|
+
}
|
|
22
|
+
return sections;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function prose(nodes: Node[], kind: string): string[] {
|
|
26
|
+
return nodes.flatMap((node) => {
|
|
27
|
+
if (!isElement(node) || ["pre", "code", BLOCKQUOTE].includes(String(node.type))) return [];
|
|
28
|
+
return node.type === kind ? [text(node)] : prose(children(node), kind);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function specDimensionsOf(source: string): Dimension[] {
|
|
33
|
+
const section = sectionsOf(source).find((entry) => /^Sections\b/.test(entry.title));
|
|
34
|
+
return prose(section?.nodes ?? [], LIST_ITEM).flatMap((item) => {
|
|
35
|
+
const match = DIMENSION.exec(item);
|
|
36
|
+
return match ? [{ id: match[1]!, done: COMPLETED.test(item.slice(match[0].length)) }] : [];
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function obligations(claim: string): string[] {
|
|
41
|
+
return [...claim.matchAll(/\bDimension\s+(\d+\.\d+)\b|\bSection\s+(\d+)\b|§\s*(\d+)\b|\b([RS]\d+)\b/gi)]
|
|
42
|
+
.map((match) => match[1] ? `Dimension ${match[1]}` : match[2] || match[3] ? `Section ${match[2] ?? match[3]}` : match[4]!);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function binds(quote: string, item: string): boolean {
|
|
46
|
+
const identifier = item.replace(/^(Dimension|Section) /, "");
|
|
47
|
+
const escaped = identifier.replaceAll(".", "\\.");
|
|
48
|
+
return new RegExp(`(?:^|[^\\w.])${escaped}(?=$|[^\\w.])`, "i").test(quote);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function unacknowledgedObligations(source: string): string[] {
|
|
52
|
+
const sections = sectionsOf(source);
|
|
53
|
+
const discovery = sections.find((entry) => /^Discovery\b/.test(entry.title));
|
|
54
|
+
const quotes = (discovery?.nodes ?? []).filter((node) => isElement(node) && node.type === BLOCKQUOTE)
|
|
55
|
+
.flatMap((node) => { const match = ACK.exec(text(node)); return match ? [match[1]!] : []; });
|
|
56
|
+
const claims = sections.flatMap((section) => [...prose(section.nodes, LIST_ITEM), ...prose(section.nodes, "p"), ...prose(section.nodes, "tr")])
|
|
57
|
+
.filter((line) => CLAIM.test(line));
|
|
58
|
+
const unacknowledged = claims.flatMap((claim) => {
|
|
59
|
+
const items = obligations(claim);
|
|
60
|
+
if (!items.length && /\bdeferred\s+(to|until|for)\b|\bMOVED\s+to\s+M\d+_\d+/i.test(claim)) return [`unnamed obligation: ${claim}`];
|
|
61
|
+
return items.filter((item) => !quotes.some((quote) => binds(quote, item)));
|
|
62
|
+
});
|
|
63
|
+
return [...new Set(unacknowledged)];
|
|
64
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { cleanupTemporaryDirectories, git, gitOutput, newRepository, orly, ROOT, temporaryDirectory } from "./gates_test_support";
|
|
6
|
+
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
7
|
+
|
|
8
|
+
const REHEARSAL_BRANCH = "feat/count-summary";
|
|
9
|
+
const ACTIVE_SPEC = "docs/v1/active/M99_001_P2_CLI_COUNT_SUMMARY.md";
|
|
10
|
+
const CLOSED_SPEC = ACTIVE_SPEC.replace("/active/", "/done/");
|
|
11
|
+
const ENTRY_PATH = "src/cli.ts";
|
|
12
|
+
const FEATURE_TESTS = "src/summary.test.ts";
|
|
13
|
+
const SOURCE_TEMPLATE = "docs/TEMPLATE.md";
|
|
14
|
+
const BODY_MARKER = "# Spec Body — Copy Everything Below This Line";
|
|
15
|
+
const BASELINE_EVIDENCE = "docs/baseline.txt";
|
|
16
|
+
const COUNTS_PATH = "fixtures/counts.txt";
|
|
17
|
+
const UNIT_COMMAND = "bun test src";
|
|
18
|
+
const CONFORM_COMMAND = "bun build src/cli.ts --outfile .orly/build/cli.js";
|
|
19
|
+
|
|
20
|
+
function processIn(root: string, argv: string[]) {
|
|
21
|
+
const result = Bun.spawnSync(argv, { cwd: root, env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: 15_000 });
|
|
22
|
+
return { code: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function filledSpec(revision: string, completed = false): string {
|
|
26
|
+
const template = readFileSync(join(ROOT, SOURCE_TEMPLATE), "utf8");
|
|
27
|
+
const body = template.split(BODY_MARKER)[1]!;
|
|
28
|
+
const banner = body.match(/<!--\s*\nSPEC AUTHORING RULES[\s\S]*?-->/)![0];
|
|
29
|
+
const proofs = ["sums_input_rows", "includes_final_unterminated_row", "refuses_invalid_input"];
|
|
30
|
+
const states = completed ? "DONE" : "IN_PROGRESS";
|
|
31
|
+
const sections: Record<string, string> = {
|
|
32
|
+
Overview: "Add a command that totals nonnegative integer rows without changing its input file.",
|
|
33
|
+
"PR Intent & comprehension handshake": "Print the exact total, including the final row without a newline. Invalid rows fail with exit 2 and no total.",
|
|
34
|
+
"Implementing agent — read these first": "1. `README.md` — public command behavior.\n2. `src/count.ts` — the existing validated integer parser.",
|
|
35
|
+
"Files Changed (blast radius)": "`src/cli.ts`, `src/summary.test.ts`, `README.md` and this specification.",
|
|
36
|
+
"Applicable Rules": "Behavior-named tests, real production entrypoint, deterministic input validation and owned file resources.",
|
|
37
|
+
"Applicable Gates": `Declared conformance: \`${CONFORM_COMMAND}\`. Full verification: \`${UNIT_COMMAND}\`.`,
|
|
38
|
+
"Prior-Art / Reference Implementations": "Use the existing count parser; tests execute the real command as a child process.",
|
|
39
|
+
"Sections (implementation slices)": proofs.map((proof, index) => `### §${index + 1} — ${proof.replaceAll("_", " ")}\n\n- **Dimension ${index + 1}.1** — ${states} — ${proof.replaceAll("_", " ")} → Test \`${proof}\``).join("\n\n"),
|
|
40
|
+
Interfaces: "`bun src/cli.ts <input>` prints one decimal total and a newline on stdout. Input is read-only.",
|
|
41
|
+
"Failure Modes": "A malformed, negative or unsafe integer fails with exit 2, a safe diagnostic and empty stdout; the input remains unchanged.",
|
|
42
|
+
Invariants: "Sum every nonempty row exactly once. Reject invalid data before printing. Keep totals within the safe integer range.",
|
|
43
|
+
"Metrics & Observability": "The command result is stdout; a safe refusal is stderr. No external service or model is used.",
|
|
44
|
+
"Test Specification (tiered)": "| Dimension | Tier | Test | Asserts |\n|---|---|---|---|\n" + proofs.map((proof, index) => `| ${index + 1}.1 | integration | \`${proof}\` | ${index === 2 ? "Invalid data yields exit 2, no total and unchanged input." : index === 1 ? "A final row without a newline is counted." : "Rows 2 and 3 yield exactly 5 and unchanged input."} |`).join("\n"),
|
|
45
|
+
"Acceptance Rubric (single scoring surface)": `| # | Criterion | Verify | Expected | Priority | Graded |\n|---|---|---|---|---|---|\n| S1 | Build the command | \`${CONFORM_COMMAND}\` | exit 0 | P0 | |\n| S2 | Public behavior | \`${UNIT_COMMAND}\` | all assertions pass | P0 | |`,
|
|
46
|
+
"Dead Code Sweep": "The command calls the existing count parser; no parallel parser or unused export is added.",
|
|
47
|
+
"Out of Scope": "External services, publishing and model calls.",
|
|
48
|
+
"Product Clarity (authoring record)": "An operator gets one exact total or a clear refusal; files and existing parsing behavior are preserved.",
|
|
49
|
+
"Decomposition & alternatives (patch vs refactor)": "Wire the existing parser into one command; a second parsing abstraction would duplicate validation.",
|
|
50
|
+
"Discovery (consult log)": "The local rehearsal tests the served command and the actual installed gate.",
|
|
51
|
+
};
|
|
52
|
+
const headings = [...body.matchAll(/^## (.+)$/gm)].map((match) => match[1]!);
|
|
53
|
+
return [banner, "# M99_001: Count summary command", `**Status:** ${states}`, "**Priority:** P2 — deterministic summary",
|
|
54
|
+
`**Branch:** \`${REHEARSAL_BRANCH}\``, `**Baseline revision:** ${revision}`,
|
|
55
|
+
"**Test Baseline:** unit=2", `**Baseline evidence:** ${BASELINE_EVIDENCE}`,
|
|
56
|
+
...headings.map((heading) => `## ${heading}\n\n${sections[heading] ?? "No additional requirements."}`),
|
|
57
|
+
].join("\n\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function consumerRehearsal(): Promise<{ root: string; revision: string }> {
|
|
61
|
+
const root = newRepository();
|
|
62
|
+
git(root, "branch", "-m", "main");
|
|
63
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
64
|
+
mkdirSync(join(root, ".orly"), { recursive: true });
|
|
65
|
+
writeFileSync(join(root, "src/count.ts"), 'export function parseCount(value: string): number {\n const count = Number(value);\n if (!/^(0|[1-9][0-9]*)$/.test(value) || !Number.isSafeInteger(count)) throw new Error("invalid count");\n return count;\n}\n');
|
|
66
|
+
writeFileSync(join(root, ENTRY_PATH), "export {};\n");
|
|
67
|
+
writeFileSync(join(root, "src/count.test.ts"), 'import {expect,test} from "bun:test";\nimport {parseCount} from "./count";\ntest("parses nonnegative integers",()=>{expect(parseCount("0")).toBe(0);expect(parseCount("12")).toBe(12);});\ntest("refuses malformed integers",()=>{expect(()=>parseCount("-1")).toThrow("invalid count");expect(()=>parseCount("bad")).toThrow("invalid count");});\n');
|
|
68
|
+
writeFileSync(join(root, ".orly/orly.json"), JSON.stringify({ schema_version: 1, packs: ["workflow.specifications"],
|
|
69
|
+
commands: { conform: [["bun", "build", ENTRY_PATH, "--outfile", ".orly/build/cli.js"]], "verify.unit": [["bun", "test", "src"]] },
|
|
70
|
+
surfaces: { user: ["src/"], docs: ["README.md", "docs/"] },
|
|
71
|
+
}));
|
|
72
|
+
writeFileSync(join(root, ".gitignore"), ".orly/build/\n");
|
|
73
|
+
const installed = orly(root, ROOT, "init", "--no-hooks");
|
|
74
|
+
if (installed.code !== 0) throw new Error(installed.output);
|
|
75
|
+
const baseline = processIn(root, ["bun", "test", "src"]);
|
|
76
|
+
if (baseline.code !== 0 || !baseline.stderr.includes("2 pass")) throw new Error(baseline.stderr);
|
|
77
|
+
mkdirSync(join(root, "docs"), { recursive: true });
|
|
78
|
+
writeFileSync(join(root, BASELINE_EVIDENCE), `${UNIT_COMMAND}\n${baseline.stdout}${baseline.stderr}`);
|
|
79
|
+
git(root, "add", "."); git(root, "commit", "-qm", "test: establish consumer baseline");
|
|
80
|
+
const revision = gitOutput(root, "rev-parse", "HEAD");
|
|
81
|
+
const origin = temporaryDirectory();
|
|
82
|
+
git(origin, "init", "--bare", "-q"); git(root, "remote", "add", "origin", origin);
|
|
83
|
+
git(root, "push", "-qu", "origin", "main");
|
|
84
|
+
git(root, "switch", "-qc", REHEARSAL_BRANCH);
|
|
85
|
+
mkdirSync(join(root, "docs/v1/active"), { recursive: true });
|
|
86
|
+
writeFileSync(join(root, ACTIVE_SPEC), filledSpec(revision));
|
|
87
|
+
git(root, "add", "."); git(root, "commit", "-qm", "chore: open count summary stream");
|
|
88
|
+
mkdirSync(join(root, "fixtures"), { recursive: true });
|
|
89
|
+
writeFileSync(join(root, COUNTS_PATH), "2\n3\n");
|
|
90
|
+
writeFileSync(join(root, FEATURE_TESTS), `import {expect,test} from "bun:test";
|
|
91
|
+
import {mkdtempSync,readFileSync,rmSync,writeFileSync} from "node:fs";
|
|
92
|
+
import {tmpdir} from "node:os";
|
|
93
|
+
import {join} from "node:path";
|
|
94
|
+
function run(input:string){const root=mkdtempSync(join(tmpdir(),"summary-input-"));try{const path=join(root,"counts.txt");writeFileSync(path,input);const result=Bun.spawnSync(["bun","${ENTRY_PATH}",path],{stdout:"pipe",stderr:"pipe"});return {code:result.exitCode,out:result.stdout.toString(),err:result.stderr.toString(),input:readFileSync(path,"utf8")};}finally{rmSync(root,{recursive:true,force:true});}}
|
|
95
|
+
test("sums_input_rows",()=>{const result=run("2\\n3\\n");expect(result.code).toBe(0);expect(result.out).toBe("5\\n");expect(result.input).toBe("2\\n3\\n");});
|
|
96
|
+
test("includes_final_unterminated_row",()=>{expect(run("2\\n3").out).toBe("5\\n");});
|
|
97
|
+
test("refuses_invalid_input",()=>{for(const input of ["2\\nbad\\n","-1\\n","9007199254740992\\n"]){const result=run(input);expect(result.code).toBe(2);expect(result.out).toBe("");expect(result.err).toContain("invalid count");expect(result.input).toBe(input);}});
|
|
98
|
+
`);
|
|
99
|
+
return { root, revision };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkpointConsumer(root: string, message: string): void {
|
|
103
|
+
git(root, "add", "."); git(root, "commit", "-qm", message);
|
|
104
|
+
git(root, "push", "-q", "origin", REHEARSAL_BRANCH);
|
|
105
|
+
git(root, "branch", "--set-upstream-to", `origin/${REHEARSAL_BRANCH}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CORRECT_ENTRY = `import {parseCount} from "./count";
|
|
109
|
+
try { const input = await Bun.file(Bun.argv[2]!).text();
|
|
110
|
+
let total = 0;
|
|
111
|
+
for (const row of input.split("\\n")) { if (!row) continue; total += parseCount(row); if (!Number.isSafeInteger(total)) throw new Error("invalid count"); }
|
|
112
|
+
console.log(total);
|
|
113
|
+
} catch { console.error("invalid count"); process.exitCode = 2; }
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
const CASE_TIMEOUT = 30_000;
|
|
117
|
+
|
|
118
|
+
afterEach(cleanupTemporaryDirectories);
|
|
119
|
+
|
|
120
|
+
test("a filled spec blocks missing production wiring and wrong behavior before accepting the real command", async () => {
|
|
121
|
+
const { root, revision } = await consumerRehearsal();
|
|
122
|
+
mkdirSync(join(root, "docs/v1/done"), { recursive: true });
|
|
123
|
+
renameSync(join(root, ACTIVE_SPEC), join(root, CLOSED_SPEC));
|
|
124
|
+
writeFileSync(join(root, CLOSED_SPEC), filledSpec(revision, true));
|
|
125
|
+
writeFileSync(join(root, "README.md"), "Run bun src/cli.ts <input> to total integer rows; invalid rows fail with exit 2.\n");
|
|
126
|
+
checkpointConsumer(root, "test: close specification with public behavior proofs");
|
|
127
|
+
|
|
128
|
+
const missing = orly(root, ROOT, "gate", "pr");
|
|
129
|
+
expect(missing.code).toBe(1);
|
|
130
|
+
expect(missing.output).toContain("cmd.verify.unit");
|
|
131
|
+
expect(missing.output).toContain("sums_input_rows");
|
|
132
|
+
expect(missing.output).toContain("3 fail");
|
|
133
|
+
expect(missing.output).not.toContain("no active spec");
|
|
134
|
+
|
|
135
|
+
writeFileSync(join(root, ENTRY_PATH), 'const input=await Bun.file(Bun.argv[2]!).text();\nconsole.log(input.trimEnd().split("\\n").length);\n');
|
|
136
|
+
checkpointConsumer(root, "test: reproduce a wrong total despite completion markers");
|
|
137
|
+
const wrong = orly(root, ROOT, "gate", "pr");
|
|
138
|
+
expect(wrong.code).toBe(1);
|
|
139
|
+
expect(wrong.output).toContain("cmd.verify.unit");
|
|
140
|
+
expect(wrong.output).toContain("sums_input_rows");
|
|
141
|
+
expect(wrong.output).toContain("3 fail");
|
|
142
|
+
expect(processIn(root, ["bun", ENTRY_PATH, "fixtures/counts.txt"]).stdout).toBe("2\n");
|
|
143
|
+
|
|
144
|
+
writeFileSync(join(root, ENTRY_PATH), CORRECT_ENTRY);
|
|
145
|
+
checkpointConsumer(root, "fix: wire validated totals into the production command");
|
|
146
|
+
const correct = orly(root, ROOT, "gate", "pr");
|
|
147
|
+
expect(correct.code).toBe(0);
|
|
148
|
+
expect(correct.output).toContain("5 pass");
|
|
149
|
+
expect(correct.output).toContain("spec.dimensions");
|
|
150
|
+
expect(correct.output).toContain("spec.moved");
|
|
151
|
+
expect(correct.output).toContain("in sync with origin/feat/count-summary");
|
|
152
|
+
|
|
153
|
+
writeFileSync(join(root, FEATURE_TESTS), (await Bun.file(join(root, FEATURE_TESTS)).text()).replace('toBe("5\\n")', 'toBe("6\\n")'));
|
|
154
|
+
checkpointConsumer(root, "test: retain verification after spec closure");
|
|
155
|
+
const afterClosure = orly(root, ROOT, "gate", "pr");
|
|
156
|
+
expect(afterClosure.code).toBe(1);
|
|
157
|
+
expect(afterClosure.output).toContain("sums_input_rows");
|
|
158
|
+
expect(afterClosure.output).toContain("4 pass");
|
|
159
|
+
expect(afterClosure.output).toContain("1 fail");
|
|
160
|
+
}, CASE_TIMEOUT);
|
|
161
|
+
|
|
162
|
+
test("the authoring skill copies the executable spec body and preserves behavior test names", async () => {
|
|
163
|
+
const { root, revision } = await consumerRehearsal();
|
|
164
|
+
const skill = await Bun.file(join(ROOT, "skills/orly-spec-new/SKILL.md")).text();
|
|
165
|
+
const copy = skill.match(/sed -n '[^\n]+\n\s*\| sed '1d' > [^\n]+/)![0];
|
|
166
|
+
const output = "docs/copied-body.md";
|
|
167
|
+
const copied = processIn(root, ["bash", "-c", copy.replace(/> .+$/, `> ${output}`).replace("docs/TEMPLATE.md", `${ROOT}/docs/TEMPLATE.md`)]);
|
|
168
|
+
expect(copied.code).toBe(0);
|
|
169
|
+
const body = await Bun.file(join(root, output)).text();
|
|
170
|
+
expect(body.trimStart()).toStartWith("<!--\nSPEC AUTHORING RULES");
|
|
171
|
+
expect(body).not.toContain("## Fill grammar");
|
|
172
|
+
expect(body).not.toContain("## Hierarchy & terminology");
|
|
173
|
+
const filled = filledSpec(revision, true);
|
|
174
|
+
expect(filled).not.toContain("{{fill:");
|
|
175
|
+
expect(filled).not.toContain("<!-- tpl:");
|
|
176
|
+
const testSource = await Bun.file(join(root, FEATURE_TESTS)).text();
|
|
177
|
+
expect(testSource).not.toMatch(/test\("(?:dim |M\d|\d+\.\d+)/);
|
|
178
|
+
expect(testSource).toContain('test("sums_input_rows"');
|
|
179
|
+
const checked = processIn(root, ["bash", ".orly/audits/spec-template.sh", "--file", ACTIVE_SPEC]);
|
|
180
|
+
expect(checked.code).toBe(0);
|
|
181
|
+
expect(checked.stdout).toContain("SPEC TEMPLATE GATE: clean (1 specs)");
|
|
182
|
+
}, CASE_TIMEOUT);
|
|
@@ -5,7 +5,7 @@ import { auditSpec } from "../audits/spec-template";
|
|
|
5
5
|
import { UNSCOPED_ENVIRONMENT } from "./git_env";
|
|
6
6
|
import { cleanupTemporaryDirectories, git, modelFor, newSpecRepository, ROOT, SPEC_RELATIVE, specFixture } from "./gates_test_support";
|
|
7
7
|
|
|
8
|
-
const CONFIG_PATH = ".
|
|
8
|
+
const CONFIG_PATH = ".orly/orly.json";
|
|
9
9
|
const STAGED = "--staged";
|
|
10
10
|
const FILE = "--file";
|
|
11
11
|
const RESIDUE = "path/to/file.ext";
|
|
@@ -22,23 +22,25 @@ function run(project: string, ...args: string[]) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
describe("spec audit through real Git and file boundaries", () => {
|
|
25
|
-
test("an invalid index fails even when its working copy was repaired", async () => {
|
|
25
|
+
test.each(["v1", "v0.9.2"])("an invalid %s index fails even when its working copy was repaired", async (version) => {
|
|
26
26
|
const project = newSpecRepository();
|
|
27
|
+
const path = SPEC_RELATIVE.replace("v1", version);
|
|
27
28
|
await modelFor(project);
|
|
28
|
-
await Bun.write(join(project,
|
|
29
|
-
git(project, "add",
|
|
30
|
-
await Bun.write(join(project,
|
|
29
|
+
await Bun.write(join(project, path), `${GOOD}\n${RESIDUE}\n`);
|
|
30
|
+
git(project, "add", path);
|
|
31
|
+
await Bun.write(join(project, path), GOOD);
|
|
31
32
|
const result = run(project, STAGED);
|
|
32
33
|
expect(result.code).toBe(1);
|
|
33
34
|
expect(result.output).toContain("unfilled template placeholder");
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
test("a repaired index passes despite invalid unstaged content and config", async () => {
|
|
37
|
+
test.each(["v1", "v0.9.2"])("a repaired %s index passes despite invalid unstaged content and config", async (version) => {
|
|
37
38
|
const project = newSpecRepository();
|
|
39
|
+
const path = SPEC_RELATIVE.replace("v1", version);
|
|
38
40
|
await modelFor(project);
|
|
39
|
-
await Bun.write(join(project,
|
|
40
|
-
git(project, "add",
|
|
41
|
-
await Bun.write(join(project,
|
|
41
|
+
await Bun.write(join(project, path), `${GOOD}\nReady.\n`);
|
|
42
|
+
git(project, "add", path);
|
|
43
|
+
await Bun.write(join(project, path), RESIDUE);
|
|
42
44
|
await Bun.write(join(project, CONFIG_PATH), "invalid json");
|
|
43
45
|
const result = run(project, STAGED);
|
|
44
46
|
expect(result.code).toBe(0);
|
package/src/surfaces.test.ts
CHANGED
|
@@ -28,6 +28,8 @@ describe("docs surface", () => {
|
|
|
28
28
|
expect(isDocs("README.md", DOCS_PREFIXES)).toBeTrue();
|
|
29
29
|
expect(isDocs("docs/v1/active/M01_001_P2_CLI_DOCS_PROCESS_AS_CODE.md", DOCS_PREFIXES)).toBeFalse();
|
|
30
30
|
expect(isDocs("docs/v2/pending/M52_001_P2_API_X.md", DOCS_PREFIXES)).toBeFalse();
|
|
31
|
+
expect(isDocs("docs/v0.9.2/active/M05_001_P2_CLI_X.md", DOCS_PREFIXES)).toBeFalse();
|
|
32
|
+
expect(isDocs("docs/v1.2/done/M05_001_P2_CLI_X.md", DOCS_PREFIXES)).toBeFalse();
|
|
31
33
|
expect(isDocs("src/agentsfleetd/main.zig", DOCS_PREFIXES)).toBeFalse();
|
|
32
34
|
});
|
|
33
35
|
});
|
package/src/surfaces.ts
CHANGED
|
@@ -7,7 +7,7 @@ const ORIGIN_PREFIX = "origin/";
|
|
|
7
7
|
const SURFACES_FIELD = "surfaces";
|
|
8
8
|
const USER_FIELD = "user";
|
|
9
9
|
const DOCS_FIELD = "docs";
|
|
10
|
-
const SPEC_TREE = /^docs\/v
|
|
10
|
+
const SPEC_TREE = /^docs\/v\d+(?:\.\d+)*\//;
|
|
11
11
|
const TEST_PATH = /(^|\/)tests?\/|\.test\.|_test\.|\.spec\./;
|
|
12
12
|
const MARKDOWN = /\.(md|mdx)$/;
|
|
13
13
|
// One fixed source-extension set across every repo: handlers (.zig), CLI and
|
package/src/validation.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { isAbsolute, join, relative } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import type { JsonObject } from "./model";
|
|
5
5
|
|
|
6
|
+
const NULL_BYTE = "\0";
|
|
7
|
+
|
|
6
8
|
export function validateRelativePath(value: unknown, label: string, errors: string[]): void {
|
|
7
9
|
if (!isString(value) || value.length === 0) {
|
|
8
10
|
errors.push(`${label} must be a string`);
|
|
@@ -22,7 +24,7 @@ export function validateSurfaces(label: string, value: unknown, errors: string[]
|
|
|
22
24
|
}
|
|
23
25
|
for (const [field, prefixes] of Object.entries(value)) {
|
|
24
26
|
if (field !== "user" && field !== "docs") errors.push(`${label} surfaces.${field} is not a known surface`);
|
|
25
|
-
else if (!Array.isArray(prefixes) || !prefixes.every((prefix) => isString(prefix) && prefix.length > 0)) {
|
|
27
|
+
else if (!Array.isArray(prefixes) || !prefixes.every((prefix) => isString(prefix) && prefix.length > 0 && !prefix.includes(NULL_BYTE))) {
|
|
26
28
|
errors.push(`${label} surfaces.${field} must be an array of path prefixes`);
|
|
27
29
|
}
|
|
28
30
|
}
|
|
@@ -34,19 +36,20 @@ export function validateCommands(label: string, value: unknown, errors: string[]
|
|
|
34
36
|
return;
|
|
35
37
|
}
|
|
36
38
|
for (const [name, invocations] of Object.entries(value)) {
|
|
39
|
+
if (!name || name.includes(NULL_BYTE)) errors.push(`${label} command name must be nonempty and contain no null byte`);
|
|
37
40
|
if (!Array.isArray(invocations) || invocations.length === 0) {
|
|
38
41
|
errors.push(`${label} command ${name} must be non-empty`);
|
|
39
42
|
continue;
|
|
40
43
|
}
|
|
41
|
-
for (const invocation of invocations) if (!Array.isArray(invocation) || invocation.length === 0 || !invocation.every((argument) => isString(argument) && argument.length > 0)) errors.push(`${label} command ${name} arguments must be strings`);
|
|
44
|
+
for (const invocation of invocations) if (!Array.isArray(invocation) || invocation.length === 0 || !invocation.every((argument) => isString(argument) && argument.length > 0 && !argument.includes(NULL_BYTE))) errors.push(`${label} command ${name} arguments must be nonempty strings without null bytes`);
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export function commandSetupErrors(commands: Record<string, string[][]>): string[] {
|
|
46
49
|
const errors: string[] = [];
|
|
47
|
-
if (!commands.conform?.length) errors.push("declare conform in .
|
|
50
|
+
if (!commands.conform?.length) errors.push("declare conform in .orly/orly.json for work checks");
|
|
48
51
|
if (!Object.keys(commands).some((name) => /^verify\..+/.test(name) && commands[name]?.length)) {
|
|
49
|
-
errors.push("declare at least one verify.* command in .
|
|
52
|
+
errors.push("declare at least one verify.* command in .orly/orly.json (for example, verify.unit or verify.docs)");
|
|
50
53
|
}
|
|
51
54
|
return errors;
|
|
52
55
|
}
|
package/src/verify.test.ts
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { RulesModel } from "./model";
|
|
7
|
-
import { packSourceErrors, verifyRenders } from "./verify";
|
|
7
|
+
import { packSourceErrors, verifyRenders, writeEvidence } from "./verify";
|
|
8
8
|
|
|
9
9
|
const ROOT = resolve(import.meta.dir, "..");
|
|
10
10
|
const PACK = "language.demo";
|
|
11
11
|
const SOURCE = "packs/demo/rules.md";
|
|
12
12
|
const TARGET = "dispatch/write_demo.md";
|
|
13
13
|
const BODY = "# Demo\n\nOne rule, stated once.\n";
|
|
14
|
+
const EVIDENCE = "evidence.json";
|
|
15
|
+
const OWNER_BYTES = "owner evidence must survive\n";
|
|
16
|
+
|
|
17
|
+
test("evidence refuses a directory symlink escape without replacing owner bytes", async () => {
|
|
18
|
+
const { model, root } = sandbox();
|
|
19
|
+
const outside = mkdtempSync(join(tmpdir(), "orly-outside-"));
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(join(outside, EVIDENCE), OWNER_BYTES);
|
|
22
|
+
symlinkSync(outside, join(root, ".orly"));
|
|
23
|
+
await expect(writeEvidence(model, "probe", [], "not-required")).rejects.toThrow("outside the target repository");
|
|
24
|
+
expect(await Bun.file(join(outside, EVIDENCE)).text()).toBe(OWNER_BYTES);
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(root, { recursive: true, force: true });
|
|
27
|
+
rmSync(outside, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
14
30
|
|
|
15
31
|
// A checkout carrying one pack whose source and target are different paths —
|
|
16
32
|
// the shape `planFiles` never writes into, and therefore the only shape that
|
package/src/verify.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { localSelection } from "./config";
|
|
5
|
-
import { managedContent } from "./
|
|
6
|
-
import { hashContent, isString, objectArray, objectValue, RulesModel } from "./model";
|
|
5
|
+
import { managedContent } from "./installation/plan";
|
|
6
|
+
import { assertWritableInside, hashContent, isString, objectArray, objectValue, RulesModel } from "./model";
|
|
7
7
|
import { Renderer } from "./render";
|
|
8
8
|
|
|
9
9
|
const PASS_RESULT = "pass";
|
|
@@ -11,6 +11,7 @@ const FAIL_RESULT = "fail";
|
|
|
11
11
|
const REGISTRY_PACKS_LABEL = "registry packs";
|
|
12
12
|
const AGENTS_FILENAME = "AGENTS.md";
|
|
13
13
|
const DETAIL_SEPARATOR = "; ";
|
|
14
|
+
const EVIDENCE_LABEL = "verification evidence";
|
|
14
15
|
|
|
15
16
|
export type VerificationCheck = {
|
|
16
17
|
name: string;
|
|
@@ -83,7 +84,9 @@ export async function writeEvidence(
|
|
|
83
84
|
checks: VerificationCheck[],
|
|
84
85
|
languageModelResult: "pass" | "not-required",
|
|
85
86
|
): Promise<string> {
|
|
86
|
-
const
|
|
87
|
+
const relativePath = ".orly/evidence.json";
|
|
88
|
+
assertWritableInside(model.root, relativePath, EVIDENCE_LABEL);
|
|
89
|
+
const path = join(model.root, relativePath);
|
|
87
90
|
mkdirSync(dirname(path), { recursive: true });
|
|
88
91
|
const commit = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: model.root, stdout: "pipe", stderr: "ignore" });
|
|
89
92
|
const evidence = {
|
|
@@ -95,6 +98,7 @@ export async function writeEvidence(
|
|
|
95
98
|
llm_result: languageModelResult,
|
|
96
99
|
created_at: new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00"),
|
|
97
100
|
};
|
|
101
|
+
assertWritableInside(model.root, relativePath, EVIDENCE_LABEL);
|
|
98
102
|
await Bun.write(path, `${JSON.stringify(evidence, null, 2)}\n`);
|
|
99
103
|
return path;
|
|
100
104
|
}
|