@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.
Files changed (155) hide show
  1. package/.githooks/pre-push +2 -1
  2. package/AGENTS.md +21 -31
  3. package/README.md +65 -29
  4. package/SOUL.md +13 -23
  5. package/audits/agents-md.md +67 -57
  6. package/audits/deinit-pairs.sh +13 -18
  7. package/audits/design-tokens.sh +9 -23
  8. package/audits/doc-read.sh +10 -33
  9. package/audits/doc-reads.ts +41 -0
  10. package/audits/error-codes.sh +18 -25
  11. package/audits/logging-rust.awk +145 -0
  12. package/audits/logging.sh +10 -166
  13. package/audits/msid-ui.sh +45 -18
  14. package/audits/rule-ledger-lib.sh +9 -6
  15. package/audits/rust-error.sh +20 -15
  16. package/audits/scope.sh +88 -0
  17. package/audits/spec-template.sh +2 -2
  18. package/audits/spec-template.ts +1 -1
  19. package/audits/sql-mod.sh +11 -14
  20. package/audits/ufs-strings.awk +164 -0
  21. package/audits/ufs.sh +10 -188
  22. package/core/operating-model.md +8 -8
  23. package/dispatch/edit_rules.md +8 -6
  24. package/dispatch/lib.sh +41 -26
  25. package/dispatch/lifecycle.md +18 -6
  26. package/dispatch/name_architecture.md +8 -3
  27. package/dispatch/verify.md +1 -1
  28. package/dispatch/write_any.md +10 -3
  29. package/dispatch/write_changelog.md +7 -4
  30. package/dispatch/write_go.md +1 -1
  31. package/dispatch/write_http.md +5 -7
  32. package/dispatch/write_python.md +1 -1
  33. package/dispatch/write_rust.md +17 -1
  34. package/dispatch/write_shell.md +1 -1
  35. package/dispatch/write_spec.md +3 -1
  36. package/dispatch/write_sql.md +28 -6
  37. package/dispatch/write_ts_adhere_bun.md +11 -0
  38. package/dispatch/write_zig.md +53 -21
  39. package/docs/CHANGELOG_VOICE.md +11 -2
  40. package/docs/EXECUTE_DOC_READS.md +10 -10
  41. package/docs/HARNESS_VERIFY_OUTPUT.md +6 -3
  42. package/docs/JUDGMENTS.md +153 -0
  43. package/docs/LIFECYCLE_PATTERNS.md +26 -16
  44. package/docs/LOGGING_STANDARD.md +39 -19
  45. package/docs/ORLY_ARCHITECTURE.md +77 -6
  46. package/docs/RELEASE_TEMPLATE.md +21 -5
  47. package/docs/REST_API_DESIGN_GUIDELINES.md +86 -21
  48. package/docs/RULE_ENFORCEMENT.md +6 -6
  49. package/docs/SCHEMA_CONVENTIONS.md +24 -3
  50. package/docs/TEMPLATE.md +4 -4
  51. package/docs/VERIFY_TIERS.md +2 -2
  52. package/docs/greptile-learnings/RULES.md +81 -26
  53. package/llms.txt +37 -34
  54. package/package.json +6 -1
  55. package/packs/language/go/rules.md +1 -1
  56. package/packs/language/python/rules.md +1 -1
  57. package/packs/language/rust/rules.md +17 -1
  58. package/packs/language/shell/rules.md +1 -1
  59. package/registry.json +22 -7
  60. package/schemas/profile.schema.json +48 -5
  61. package/skills/orly-babysit-prs/SKILL.md +87 -34
  62. package/skills/orly-spec-new/SKILL.md +19 -6
  63. package/skills/orly-write-integration-test/SKILL.md +4 -4
  64. package/skills/orly-write-unit-test/SKILL.md +10 -1
  65. package/src/cli.test.ts +2 -2
  66. package/src/cli.ts +19 -75
  67. package/src/cli_help.ts +69 -0
  68. package/src/command_limits.ts +20 -0
  69. package/src/command_process.test.ts +109 -0
  70. package/src/command_process.ts +88 -0
  71. package/src/command_runner.test.ts +88 -0
  72. package/src/command_runner.ts +33 -0
  73. package/src/comprehension_cache.test.ts +123 -0
  74. package/src/config.test.ts +5 -4
  75. package/src/config.ts +26 -164
  76. package/src/config_discovery.ts +159 -0
  77. package/src/config_validation.test.ts +56 -0
  78. package/src/config_validation.ts +25 -0
  79. package/src/criteria.test.ts +1 -1
  80. package/src/criteria.ts +10 -26
  81. package/src/criteria_spec.ts +12 -20
  82. package/src/criteria_support.ts +2 -19
  83. package/src/doc_reads.test.ts +42 -0
  84. package/src/doc_rules.ts +2 -2
  85. package/src/evaluation_resources.test.ts +64 -0
  86. package/src/execution/config.ts +22 -0
  87. package/src/execution/plan.test.ts +65 -0
  88. package/src/execution/plan.ts +51 -0
  89. package/src/gates.ts +39 -24
  90. package/src/gates_spec.test.ts +110 -4
  91. package/src/gates_test_support.ts +9 -7
  92. package/src/install.test.ts +38 -222
  93. package/src/install.ts +117 -308
  94. package/src/install_loaders.test.ts +203 -0
  95. package/src/install_nested.test.ts +2 -2
  96. package/src/install_packs.test.ts +66 -6
  97. package/src/installation/hooks.ts +63 -0
  98. package/src/installation/lock.test.ts +97 -0
  99. package/src/installation/lock.ts +56 -0
  100. package/src/installation/migration.test.ts +212 -0
  101. package/src/installation/migration.ts +65 -0
  102. package/src/installation/plan.ts +96 -0
  103. package/src/installation/transaction.ts +126 -0
  104. package/src/installation_preservation.test.ts +48 -0
  105. package/src/judgments/advice.test.ts +38 -0
  106. package/src/judgments/advice.ts +32 -0
  107. package/src/judgments/command.integration.test.ts +47 -0
  108. package/src/judgments/command.ts +90 -0
  109. package/src/judgments/constants.ts +55 -0
  110. package/src/judgments/evidence.test.ts +48 -0
  111. package/src/judgments/evidence.ts +34 -0
  112. package/src/judgments/files.test.ts +62 -0
  113. package/src/judgments/files.ts +73 -0
  114. package/src/judgments/questions.test.ts +19 -0
  115. package/src/judgments/questions.ts +85 -0
  116. package/src/judgments/replay.test.ts +43 -0
  117. package/src/judgments/replay.ts +66 -0
  118. package/src/judgments/runner.integration.test.ts +165 -0
  119. package/src/judgments/runner.ts +97 -0
  120. package/src/judgments/scan.test.ts +19 -0
  121. package/src/judgments/scan.ts +40 -0
  122. package/src/judgments/syntax.test.ts +115 -0
  123. package/src/judgments/syntax.ts +71 -0
  124. package/src/judgments/syntax_worker.ts +70 -0
  125. package/src/judgments/test_support.ts +60 -0
  126. package/src/judgments/transport.integration.test.ts +63 -0
  127. package/src/judgments/transport.ts +35 -0
  128. package/src/judgments/types.test.ts +43 -0
  129. package/src/judgments/types.ts +66 -0
  130. package/src/judgments/wire.test.ts +48 -0
  131. package/src/judgments/wire.ts +40 -0
  132. package/src/loaders.test.ts +13 -16
  133. package/src/loaders.ts +28 -10
  134. package/src/model.test.ts +20 -3
  135. package/src/model.ts +7 -18
  136. package/src/pack_hygiene.test.ts +42 -1
  137. package/src/pack_hygiene.ts +18 -8
  138. package/src/references.ts +15 -7
  139. package/src/release_inventory.test.ts +80 -0
  140. package/src/render.test.ts +1 -1
  141. package/src/render.ts +3 -8
  142. package/src/required_scanner.test.ts +36 -0
  143. package/src/review_poll.test.ts +158 -0
  144. package/src/scanner_scope.test.ts +202 -0
  145. package/src/setup.test.ts +9 -6
  146. package/src/spec_baseline.test.ts +1 -1
  147. package/src/spec_completion.test.ts +74 -0
  148. package/src/spec_obligations.ts +64 -0
  149. package/src/spec_rehearsal.test.ts +182 -0
  150. package/src/spec_template_io.test.ts +11 -9
  151. package/src/surfaces.test.ts +2 -0
  152. package/src/surfaces.ts +1 -1
  153. package/src/validation.ts +7 -4
  154. package/src/verify.test.ts +18 -2
  155. package/src/verify.ts +7 -3
@@ -1,6 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
+ import { renderProfileSourceLines } from "./references";
5
+ import { isString } from "./model";
6
+
4
7
  /**
5
8
  * Make targets that belong to ONE consuming repository.
6
9
  *
@@ -40,6 +43,7 @@ export const PRODUCT_ONLY_TARGETS: readonly string[] = [
40
43
  * the generic standard, which is what core documents are.
41
44
  */
42
45
  const CORE_PACK = "core";
46
+ const PRODUCT_PREFIX = "product.";
43
47
 
44
48
  /** A file shipped by a pack, with the pack that ships it. */
45
49
  export interface ShippedFile {
@@ -50,21 +54,25 @@ export interface ShippedFile {
50
54
  /** Collects every `source` a registry node ships, tagged with its pack. */
51
55
  export function shippedFiles(registry: unknown): ShippedFile[] {
52
56
  const out: ShippedFile[] = [];
53
- const walk = (node: unknown, pack: string): void => {
57
+ const walk = (node: unknown, pack: string, coreDocument = false): void => {
54
58
  if (Array.isArray(node)) {
55
- for (const item of node) walk(item, pack);
59
+ for (const item of node) walk(item, pack, coreDocument);
60
+ return;
61
+ }
62
+ if (coreDocument && isString(node)) {
63
+ out.push({ source: node, pack });
56
64
  return;
57
65
  }
58
66
  if (node === null || typeof node !== "object") return;
59
67
  const record = node as Record<string, unknown>;
60
- if (typeof record.source === "string") out.push({ source: record.source, pack });
68
+ if (isString(record.source)) out.push({ source: record.source, pack });
61
69
  for (const value of Object.values(record)) walk(value, pack);
62
70
  };
63
71
 
64
72
  const root = registry as Record<string, unknown>;
65
73
  const packs = (root.packs ?? {}) as Record<string, unknown>;
66
74
  for (const [name, pack] of Object.entries(packs)) walk(pack, name);
67
- walk(root.core_documents, CORE_PACK);
75
+ walk(root.core_documents, CORE_PACK, true);
68
76
  walk(root.rules, CORE_PACK);
69
77
  return out;
70
78
  }
@@ -77,9 +85,11 @@ export function shippedFiles(registry: unknown): ShippedFile[] {
77
85
  export async function productLeakErrors(root: string, registry: unknown): Promise<string[]> {
78
86
  const errors: string[] = [];
79
87
  const seen = new Set<string>();
88
+ const knownPacks = new Set(Object.keys((registry as { packs?: object }).packs ?? {}));
89
+ const genericPacks = new Set([...knownPacks].filter((pack) => !pack.startsWith(PRODUCT_PREFIX)));
80
90
 
81
91
  for (const { source, pack } of shippedFiles(registry)) {
82
- if (pack.startsWith("product.")) continue;
92
+ if (pack.startsWith(PRODUCT_PREFIX)) continue;
83
93
  if (!source.endsWith(".md")) continue;
84
94
  if (seen.has(source)) continue;
85
95
  seen.add(source);
@@ -91,19 +101,19 @@ export async function productLeakErrors(root: string, registry: unknown): Promis
91
101
  continue; // absent sources are the reference gate's business, not ours
92
102
  }
93
103
 
94
- text.split("\n").forEach((line, index) => {
104
+ for (const { text: line, lineNumber } of renderProfileSourceLines(text, genericPacks, knownPacks, source)) {
95
105
  for (const target of PRODUCT_ONLY_TARGETS) {
96
106
  // Word-boundary on the tail so `test-integration` does not swallow
97
107
  // `test-integration-db`'s own, more specific, report.
98
108
  const pattern = new RegExp(`make ${target}(?![a-z0-9_-])`);
99
109
  if (pattern.test(line)) {
100
110
  errors.push(
101
- `${source}:${index + 1}: pack '${pack}' is not product-scoped but names \`make ${target}\`, ` +
111
+ `${source}:${lineNumber}: pack '${pack}' is not product-scoped but names \`make ${target}\`, ` +
102
112
  `which only one repository defines — say what the command IS instead`,
103
113
  );
104
114
  }
105
115
  }
106
- });
116
+ }
107
117
  }
108
118
  return errors;
109
119
  }
package/src/references.ts CHANGED
@@ -8,7 +8,7 @@ const PACK_START = /^[ \t]*<!--[ \t]*oracle-packs:start ([^>]+)[ \t]*-->[ \t]*$/
8
8
  const PACK_END = /^[ \t]*<!--[ \t]*oracle-packs:end[ \t]*-->[ \t]*$/;
9
9
  const CODE_FENCE = /^[ \t]*(?:```|~~~)/;
10
10
  const MARKDOWN_LINK = /\[[^\]]*\]\(([^)]+)\)/g;
11
- const DISPATCH_REFERENCE = /dispatch\/[A-Za-z0-9_.-]+\.md/g;
11
+ const DISPATCH_REFERENCE = /(?:\.orly\/)?dispatch\/[A-Za-z0-9_.-]+\.md/g;
12
12
  const COMMENT_OPEN = "<!--";
13
13
  const COMMENT_CLOSE = "-->";
14
14
  const NEWLINE = "\n";
@@ -19,16 +19,22 @@ export function renderProfileText(
19
19
  knownPacks: Set<string>,
20
20
  source: string,
21
21
  ): string {
22
- return walkPackMarkers(content, selectedPacks, knownPacks, source).join(NEWLINE).trim();
22
+ return renderProfileSourceLines(content, selectedPacks, knownPacks, source)
23
+ .map(({ text }) => text).join(NEWLINE).trim();
23
24
  }
24
25
 
25
- function walkPackMarkers(
26
+ export interface ProfileSourceLine {
27
+ readonly text: string;
28
+ readonly lineNumber: number;
29
+ }
30
+
31
+ export function renderProfileSourceLines(
26
32
  content: string,
27
33
  selectedPacks: Set<string>,
28
34
  knownPacks: Set<string>,
29
35
  source: string,
30
- ): string[] {
31
- const rendered: string[] = [];
36
+ ): ProfileSourceLine[] {
37
+ const rendered: ProfileSourceLine[] = [];
32
38
  let activeBlock: string[] | undefined;
33
39
  let includeBlock = true;
34
40
  const lines = content.split(/\r?\n/);
@@ -51,10 +57,12 @@ function walkPackMarkers(
51
57
  const match = line.match(PACK_LINE);
52
58
  if (match) {
53
59
  const names = packNames(match[2] ?? "", knownPacks, source, lineNumber);
54
- if (names.some((name) => selectedPacks.has(name))) rendered.push((match[1] ?? "").trimEnd());
60
+ if (names.some((name) => selectedPacks.has(name))) {
61
+ rendered.push({ text: (match[1] ?? "").trimEnd(), lineNumber });
62
+ }
55
63
  continue;
56
64
  }
57
- rendered.push(line);
65
+ rendered.push({ text: line, lineNumber });
58
66
  }
59
67
  if (activeBlock) throw new OrlyError(`${source}: unclosed orly pack block`);
60
68
  return rendered;
@@ -0,0 +1,80 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, git, newRepository, ROOT } from "./gates_test_support";
6
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
7
+
8
+ const INVENTORY = "evals/release/inventory.json";
9
+ const REVIEWED_RECORD = "evals/release/reviewed.json";
10
+ const SCRIPT = "evals/release/inventory.ts";
11
+ const SOURCE = "src/subject.ts";
12
+ const CONTENT = "export const value = true;\n";
13
+ const REVIEWED = "reviewed";
14
+ const PENDING = "pending";
15
+ const ENCODING = "utf8";
16
+ const TEST_TIMEOUT_MS = 20_000;
17
+ const EMPTY_DIGEST = `sha256:${new Bun.CryptoHasher("sha256").update("").digest("hex")}`;
18
+ type Row = { path: string; present: boolean; package: boolean; digest: string; review: { status: string; evidence: string; previousReview?: { reviewedDigest?: string } } };
19
+
20
+ afterEach(cleanupTemporaryDirectories);
21
+
22
+ function fixture() {
23
+ const repo = newRepository();
24
+ mkdirSync(join(repo, dirname(SCRIPT)), { recursive: true });
25
+ mkdirSync(join(repo, dirname(SOURCE)), { recursive: true });
26
+ copyFileSync(join(ROOT, SCRIPT), join(repo, SCRIPT));
27
+ writeFileSync(join(repo, SOURCE), CONTENT);
28
+ writeFileSync(join(repo, "package.json"), JSON.stringify({ name: "inventory-fixture", version: "1.0.0", files: ["src"] }));
29
+ writeFileSync(join(repo, "registry.json"), JSON.stringify({ core_documents: [], packs: {} }));
30
+ git(repo, "add", ".");
31
+ git(repo, "commit", "-qm", "fixture sources");
32
+ return repo;
33
+ }
34
+
35
+ function review(repo: string, digest?: string) {
36
+ writeFileSync(join(repo, REVIEWED_RECORD), JSON.stringify({
37
+ [SOURCE]: { status: REVIEWED, evidence: "Reviewed these exact fixture bytes.", findings: ["A41"], ...(digest === undefined ? {} : { reviewedDigest: digest }) },
38
+ [INVENTORY]: { status: REVIEWED, evidence: "Self review is insufficient.", findings: [], reviewedDigest: EMPTY_DIGEST },
39
+ [REVIEWED_RECORD]: { status: REVIEWED, evidence: "Self review is insufficient.", findings: [], reviewedDigest: EMPTY_DIGEST },
40
+ }));
41
+ }
42
+
43
+ function rows(repo: string): Row[] {
44
+ const result = Bun.spawnSync(["bun", join(repo, SCRIPT)], { cwd: repo, env: UNSCOPED_ENVIRONMENT, stdout: "pipe", stderr: "pipe", timeout: TEST_TIMEOUT_MS });
45
+ expect(result.stderr.toString()).toBe("");
46
+ expect(result.exitCode).toBe(0);
47
+ return (JSON.parse(readFileSync(join(repo, INVENTORY), ENCODING)) as { files: Row[] }).files;
48
+ }
49
+
50
+ test("inventory_covers_repository_and_package without inheriting a changed-byte review", () => {
51
+ const repo = fixture();
52
+ const digest = `sha256:${new Bun.CryptoHasher("sha256").update(CONTENT).digest("hex")}`;
53
+ review(repo, digest);
54
+ const initial = rows(repo);
55
+ expect(initial.find((row) => row.path === SOURCE)).toMatchObject({ present: true, package: true, digest, review: { status: REVIEWED } });
56
+ writeFileSync(join(repo, SOURCE), "export const value = false;\n");
57
+ const changed = rows(repo).find((row) => row.path === SOURCE)!;
58
+ expect(changed.review.status).toBe(PENDING);
59
+ expect(changed.digest).not.toBe(digest);
60
+ expect(changed.review.previousReview?.reviewedDigest).toBe(digest);
61
+ expect(changed.package).toBeTrue();
62
+ }, TEST_TIMEOUT_MS);
63
+
64
+ test("a review missing a digest cannot authorize current bytes", () => {
65
+ const repo = fixture();
66
+ review(repo);
67
+ expect(rows(repo).find((row) => row.path === SOURCE)?.review.status).toBe(PENDING);
68
+ }, TEST_TIMEOUT_MS);
69
+
70
+ test("inventory and review records require an external snapshot binding", () => {
71
+ const repo = fixture();
72
+ review(repo, EMPTY_DIGEST);
73
+ const current = rows(repo);
74
+ for (const path of [INVENTORY, REVIEWED_RECORD]) {
75
+ const row = current.find((candidate) => candidate.path === path)!;
76
+ expect(row.review.status).toBe(PENDING);
77
+ expect(row.review.evidence).toContain("independently bound snapshot");
78
+ expect(row.package).toBeFalse();
79
+ }
80
+ }, TEST_TIMEOUT_MS);
@@ -12,7 +12,7 @@ const AGENTSFLEET_PACKS = ["universal.authoring", "language.zig", "domain.sql",
12
12
  const CACHE_KIT_PACKS = ["universal.authoring", "language.rust", "language.shell", "domain.documentation"];
13
13
  const DOCS_PACKS = ["universal.authoring", "language.mdx", "domain.documentation", "domain.changelog"];
14
14
  const KERNEL_PACKS = ["universal.authoring", "language.zig", "language.rust", "domain.sql"];
15
- // This repository's own selection, as `.oracle/orly.json` declares it — the
15
+ // This repository's own selection, as `.orly/orly.json` declares it — the
16
16
  // root render must stay current against exactly what `orly update` writes.
17
17
  const DOTFILES_PACKS = ["universal.authoring", "language.zig", "language.typescript", "language.javascript", "language.rust", "language.go", "language.python", "language.shell", "language.mdx", "domain.sql", "domain.http", "domain.auth", "domain.documentation", "domain.changelog", "workflow.specifications", "workflow.governance", "product.agentsfleet", "persona.indy", "workflow.skills"];
18
18
  const DOTFILES_COMMANDS = { conform: [["make", "conform"]], "verify.unit": [["bun", "test", "src"]] };
package/src/render.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { lstatSync, mkdirSync } from "node:fs";
2
- import { dirname, join, relative } from "node:path";
1
+ import { lstatSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
3
 
4
4
  import {
5
5
  isString,
@@ -79,16 +79,11 @@ export class Renderer {
79
79
  const rendered = invocations.map((invocation) => Array.isArray(invocation) ? `\`${invocation.map((argument) => quoteArgument(String(argument))).join(" ")}\`` : "").join("<br>");
80
80
  rows.push(`| \`${name}\` | ${rendered} |`);
81
81
  }
82
- if (Object.keys(commands).length === 0) rows.push("| None | Declare them in `.oracle/orly.json` |");
82
+ if (Object.keys(commands).length === 0) rows.push("| None | Declare them in `.orly/orly.json` |");
83
83
  return rows.join(NEWLINE);
84
84
  }
85
85
  }
86
86
 
87
- async function writeText(path: string, content: string): Promise<void> {
88
- mkdirSync(dirname(path), { recursive: true });
89
- await Bun.write(path, content);
90
- }
91
-
92
87
  function quoteArgument(value: string): string {
93
88
  return /^[A-Za-z0-9_./:=+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
94
89
  }
@@ -0,0 +1,36 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, 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 TOOL_DIRECTORY = "tools";
9
+ const HOOK = ".githooks/pre-push";
10
+
11
+ afterEach(cleanupTemporaryDirectories);
12
+
13
+ function runHook(scannerExit?: number) {
14
+ const root = newRepository();
15
+ const tools = join(root, TOOL_DIRECTORY);
16
+ mkdirSync(tools);
17
+ symlinkSync(Bun.which("git")!, join(tools, "git"));
18
+ if (scannerExit !== undefined) writeFileSync(join(tools, "gitleaks"), `#!/bin/sh\nexit ${scannerExit}\n`, { mode: 0o755 });
19
+ return Bun.spawnSync(["/bin/bash", join(ROOT, HOOK)], { cwd: root, env: { ...UNSCOPED_ENVIRONMENT, PATH: tools }, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
20
+ }
21
+
22
+ test("a push fails when its required scanner is missing", () => {
23
+ const result = runHook();
24
+ expect(result.exitCode).toBe(1);
25
+ expect(result.stderr.toString()).toContain("required gitleaks is not installed");
26
+ });
27
+
28
+ test("a clean scan permits an unrelated push", () => {
29
+ expect(runHook(0).exitCode).toBe(0);
30
+ });
31
+
32
+ test("a scanner finding blocks the push", () => {
33
+ const result = runHook(1);
34
+ expect(result.exitCode).toBe(1);
35
+ expect(result.stderr.toString()).toContain("gitleaks found a secret");
36
+ });
@@ -0,0 +1,158 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, gitOutput, newRepository, ROOT } from "./gates_test_support";
6
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
7
+
8
+ const SKILL_PATH = "skills/orly-babysit-prs/SKILL.md";
9
+ const OTHER_BOT = "other-review-bot";
10
+ const skill = await Bun.file(join(ROOT, SKILL_PATH)).text();
11
+ const codeIn = (section: string) => section.match(/```bash\n([\s\S]*?)\n```/)![1]!;
12
+ const reviews = codeIn(skill.slice(skill.indexOf("## Polling loop")));
13
+ const checks = codeIn(skill.slice(skill.indexOf("## STEP 0.75")));
14
+
15
+ afterEach(cleanupTemporaryDirectories);
16
+
17
+ function poll(mode = "complete", state = "green", botOverride?: string) {
18
+ const root = newRepository();
19
+ const tools = join(root, "tools");
20
+ mkdirSync(tools);
21
+ for (const name of ["jq", "git"]) symlinkSync(Bun.which(name)!, join(tools, name));
22
+ const head = gitOutput(root, "rev-parse", "HEAD");
23
+ const calls = join(root, "calls.jsonl");
24
+ writeFileSync(join(tools, "gh"), `#!${process.execPath}
25
+ const args=Bun.argv.slice(2);
26
+ await Bun.write(Bun.file(process.env.POLL_CALLS), (await Bun.file(process.env.POLL_CALLS).exists() ? await Bun.file(process.env.POLL_CALLS).text() : "") + JSON.stringify(args) + "\\n");
27
+ if(args[0]==="repo") console.log("example/project");
28
+ else if(args[0]==="pr" && args[1]==="view") {
29
+ if(args.includes("number")) console.log("7");
30
+ else { const prior=(await Bun.file(process.env.POLL_CALLS).text()).split("\\n").filter(x=>x.includes("headRefOid")).length; console.log((process.env.POLL_MODE==="changed" && prior>1) || (process.env.POLL_MODE==="changed-check" && prior>2) ? "changed-revision" : process.env.POLL_HEAD); }
31
+ } else if(args[0]==="pr" && args[1]==="checks") {
32
+ if(process.env.POLL_STATE==="fetch-failed"){console.error("temporary fetch refusal");process.exit(2);}
33
+ console.log(JSON.stringify(process.env.POLL_STATE==="empty" ? [] : [{name:"tests",state:"SUCCESS",bucket:process.env.POLL_STATE==="failed" ? "fail" : "pass",link:"https://example.invalid/check"}]));
34
+ } else if(args[0]==="api") {
35
+ if(process.env.POLL_MODE==="fetch-failed" && args[1].includes("/issues/")){console.error("temporary summary refusal");process.exit(2);}
36
+ const revision=process.env.POLL_MODE==="stale" ? "old-revision" : process.env.POLL_HEAD;
37
+ const login=process.env.POLL_MODE===${JSON.stringify(OTHER_BOT)} ? process.env.POLL_MODE : "greptile";
38
+ const first={id:1,user:{login},body:"first-page finding",commit_id:revision,state:"COMMENTED"};
39
+ const second={id:2,user:{login},body:"second-page finding",commit_id:revision,state:"COMMENTED",pull_request_review_id:2};
40
+ if(process.env.POLL_MODE==="missing-review") console.log("[[]]");
41
+ else if(process.env.POLL_MODE==="malformed") console.log(JSON.stringify([{message:"not review evidence"}]));
42
+ else console.log(JSON.stringify(args.includes("--paginate") && args.includes("--slurp") ? [[first],[second]] : [[first]]));
43
+ } else process.exit(2);
44
+ `, { mode: 0o755 });
45
+ const result = Bun.spawnSync(["/bin/bash", "-c", `${reviews}\n${checks}`], {
46
+ cwd: root, env: { ...UNSCOPED_ENVIRONMENT, ...(botOverride === undefined ? {} : { REVIEW_BOT_PATTERN: botOverride }), PATH: tools, POLL_HEAD: head, POLL_MODE: mode, POLL_STATE: state, POLL_CALLS: calls },
47
+ stdout: "pipe", stderr: "pipe", timeout: 10_000,
48
+ });
49
+ return { code: result.exitCode, output: result.stdout.toString() + result.stderr.toString(), root, head };
50
+ }
51
+
52
+ test("review and summary fetches include every page and bind checks to the pushed revision", async () => {
53
+ const result = poll();
54
+ expect(result.code, result.output).toBe(0);
55
+ const calls: string[][] = (await Bun.file(join(result.root, "calls.jsonl")).text()).trim().split("\n").map((line) => JSON.parse(line));
56
+ const endpoints = calls.filter((args) => args[0] === "api");
57
+ expect(endpoints).toHaveLength(3);
58
+ for (const args of endpoints) { expect(args).toContain("--paginate"); expect(args).toContain("--slurp"); }
59
+ expect(result.output).toContain("second-page finding");
60
+ expect(result.output).toContain(`ci=green revision=${result.head}`);
61
+ });
62
+
63
+ test("an unsupported bot override cannot certify feedback excluded from triage", () => {
64
+ const result = poll(OTHER_BOT, "green", OTHER_BOT);
65
+ expect(result.code).not.toBe(0);
66
+ expect(result.output).toContain("current revision review pending");
67
+ expect(result.output).not.toContain("ci=green");
68
+ });
69
+
70
+ for (const mode of ["fetch-failed", "malformed", "changed", "changed-check", "stale", "missing-review"]) {
71
+ test(`incomplete ${mode} review evidence cannot establish a green poll`, () => {
72
+ const result = poll(mode);
73
+ expect(result.code).not.toBe(0);
74
+ expect(result.output).not.toContain("ci=green");
75
+ });
76
+ }
77
+
78
+ for (const state of ["empty", "fetch-failed", "failed"]) {
79
+ test(`${state} check discovery stays incomplete`, () => {
80
+ const result = poll("complete", state);
81
+ expect(result.code).not.toBe(0);
82
+ expect(result.output).toContain("reset quiet polls");
83
+ expect(result.output).not.toContain("ci=green");
84
+ });
85
+ }
86
+
87
+ function gitlabPoll(mode = "complete", state = "green") {
88
+ const root = newRepository();
89
+ const tools = join(root, "tools");
90
+ mkdirSync(tools);
91
+ for (const name of ["jq", "git"]) symlinkSync(Bun.which(name)!, join(tools, name));
92
+ const head = gitOutput(root, "rev-parse", "HEAD");
93
+ const calls = join(root, "calls.jsonl");
94
+ writeFileSync(join(tools, "glab"), `#!${process.execPath}
95
+ const args=Bun.argv.slice(2);
96
+ await Bun.write(Bun.file(process.env.POLL_CALLS), (await Bun.file(process.env.POLL_CALLS).exists() ? await Bun.file(process.env.POLL_CALLS).text() : "") + JSON.stringify(args) + "\\n");
97
+ if(args.includes("--jq")) process.exit(2);
98
+ if(args[0]==="repo") console.log(JSON.stringify({path_with_namespace:"example/team/project"}));
99
+ else if(args[0]==="mr") {
100
+ const prior=(await Bun.file(process.env.POLL_CALLS).text()).split("\\n").filter(x=>x.includes('"mr"')).length;
101
+ const changed=(process.env.POLL_MODE==="changed" && prior>2) || (process.env.POLL_MODE==="changed-check" && prior>3);
102
+ console.log(JSON.stringify({iid:7,sha:changed ? "changed-revision" : process.env.POLL_HEAD}));
103
+ } else if(args[0]==="api") {
104
+ const endpoint=args[1];
105
+ if(process.env.POLL_MODE==="fetch-failed" && endpoint.endsWith("/notes")){console.error("temporary summary refusal");process.exit(2);}
106
+ if(endpoint.endsWith("/pipelines")) {
107
+ if(process.env.POLL_STATE==="fetch-failed"){console.error("temporary pipeline refusal");process.exit(2);}
108
+ console.log(JSON.stringify(process.env.POLL_STATE==="empty" ? [] : [{id:3,sha:process.env.POLL_HEAD,status:process.env.POLL_STATE==="failed" ? "failed" : "success"}]));
109
+ } else if(process.env.POLL_MODE==="malformed") console.log(JSON.stringify({message:"not review evidence"}));
110
+ else {
111
+ const note=id=>({id,author:{username:"greptile"},body:id===1 ? "first-page finding" : "second-page finding",type:null});
112
+ const discussion=id=>({id:String(id),notes:[note(id)]});
113
+ const item=endpoint.endsWith("/discussions") ? discussion : note;
114
+ console.log(JSON.stringify([item(1)]));
115
+ if(args.includes("--paginate")) console.log(JSON.stringify([item(2)]));
116
+ }
117
+ } else process.exit(2);
118
+ `, { mode: 0o755 });
119
+ const reviewCode = codeIn(skill.slice(skill.indexOf("### GitLab (`FORGE=gitlab`)")));
120
+ const checkCode = codeIn(skill.slice(skill.indexOf("### GitLab\n")));
121
+ const result = Bun.spawnSync(["/bin/bash", "-c", `${reviewCode}\n${checkCode}`], {
122
+ cwd: root, env: { ...UNSCOPED_ENVIRONMENT, PATH: tools, POLL_HEAD: head, POLL_MODE: mode, POLL_STATE: state, POLL_CALLS: calls },
123
+ stdout: "pipe", stderr: "pipe", timeout: 10_000,
124
+ });
125
+ return { code: result.exitCode, output: result.stdout.toString() + result.stderr.toString(), root, head };
126
+ }
127
+
128
+ test("GitLab polling collects nested project review pages using supported flags", async () => {
129
+ const result = gitlabPoll();
130
+ expect(result.code, result.output).toBe(0);
131
+ const calls: string[][] = (await Bun.file(join(result.root, "calls.jsonl")).text()).trim().split("\n").map((line) => JSON.parse(line));
132
+ const endpoints = calls.filter((args) => args[0] === "api");
133
+ expect(endpoints).toHaveLength(3);
134
+ for (const args of endpoints) {
135
+ expect(args[1]).toStartWith("projects/example%2Fteam%2Fproject/");
136
+ expect(args).toContain("--paginate");
137
+ expect(args).not.toContain("--jq");
138
+ }
139
+ expect(result.output).toContain("second-page finding");
140
+ expect(result.output).toContain(`ci=green revision=${result.head}`);
141
+ });
142
+
143
+ for (const mode of ["fetch-failed", "malformed", "changed", "changed-check"]) {
144
+ test(`GitLab incomplete ${mode} evidence cannot establish a green poll`, () => {
145
+ const result = gitlabPoll(mode);
146
+ expect(result.code).not.toBe(0);
147
+ expect(result.output).not.toContain("ci=green");
148
+ });
149
+ }
150
+
151
+ for (const state of ["empty", "fetch-failed", "failed"]) {
152
+ test(`GitLab ${state} pipeline discovery stays incomplete`, () => {
153
+ const result = gitlabPoll("complete", state);
154
+ expect(result.code).not.toBe(0);
155
+ expect(result.output).toContain("reset quiet polls");
156
+ expect(result.output).not.toContain("ci=green");
157
+ });
158
+ }
@@ -0,0 +1,202 @@
1
+ import { afterEach, expect, setDefaultTimeout, test } from "bun:test";
2
+ import { statSync, symlinkSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { cleanupTemporaryDirectories, git, newRepository, ROOT, temporaryDirectory } from "./gates_test_support";
6
+ import { UNSCOPED_ENVIRONMENT } from "./git_env";
7
+
8
+ const TEST_TIMEOUT_MS = 30_000;
9
+ const PRIVATE_DIRECTORY_MODE = 0o700;
10
+ const PERMISSION_BITS = 0o777;
11
+ const CONCURRENT_RECEIPTS = 2;
12
+ const SECONDS_PER_MINUTE = 60;
13
+ const STAGED = "--staged";
14
+ const ALL = "--all";
15
+ const ADD = "add";
16
+ const COMMIT = "commit";
17
+ const SOURCE = "lib/odd 'quote\" back\\slash.ts";
18
+ const ZIG_SOURCE = "lib/runtime one.zig";
19
+ const RUST_SOURCE = "crates/runtime one/src/error 'quoted.rs";
20
+ const ALIAS_SOURCE = "crates/runtime one/src/result alias.rs";
21
+ const MANIFEST = "crates/runtime one/Cargo.toml";
22
+ const CONFIG = ".orly/orly.json";
23
+ const CLEAN = "export const MESSAGE = \"once\";\n";
24
+ const DUPLICATE = "export function first() { return \"duplicate value\"; }\nexport function second() { return \"duplicate value\"; }\n";
25
+ const NUMERIC = "export function seconds(minutes: number) { return minutes * 60; }\n";
26
+ const MARKER = "M" + "9_001";
27
+ const MARKED = `// ${MARKER}\nexport const VALUE = true;\n`;
28
+ const OWNED_ALIAS = "pub type Result<T, E = Error> = core::result::Result<T, E>;\n";
29
+ const ERROR_TYPE = "pub struct Error;\n";
30
+ const VALUE_INIT = "pub fn init() Self { return .{}; }\n";
31
+ const ALLOCATING_INIT = "pub fn init(a: std.mem.Allocator) !Self { const owned = try a.alloc(u8, COUNT); return .{ .owned = owned }; }\n";
32
+ const DIFF = "--diff";
33
+ const SCANNERS = ["ufs.sh", "logging.sh", "deinit-pairs.sh", "sql-mod.sh", "error-codes.sh", "rust-error.sh", "design-tokens.sh"];
34
+
35
+ setDefaultTimeout(TEST_TIMEOUT_MS);
36
+ afterEach(cleanupTemporaryDirectories);
37
+
38
+ function scan(project: string, script: string, args: string[], env: Record<string, string> = {}) {
39
+ const result = Bun.spawnSync(["bash", join(ROOT, script), ...args], {
40
+ cwd: project, env: { ...UNSCOPED_ENVIRONMENT, TMPDIR: temporaryDirectory(), ...env },
41
+ stdout: "pipe", stderr: "pipe", timeout: TEST_TIMEOUT_MS,
42
+ });
43
+ return { code: result.exitCode, text: result.stdout.toString() + result.stderr.toString() };
44
+ }
45
+
46
+ async function staged(project: string, path: string, content: string) {
47
+ await Bun.write(join(project, path), content);
48
+ git(project, ADD, path);
49
+ }
50
+
51
+ test.each([true, false])("staged literal checks grade index bytes with staged violation %s", async (badIndex) => {
52
+ const project = newRepository();
53
+ await staged(project, SOURCE, badIndex ? DUPLICATE : CLEAN);
54
+ await Bun.write(join(project, SOURCE), badIndex ? CLEAN : DUPLICATE);
55
+ const result = scan(project, "audits/ufs.sh", [STAGED], { ORLY_AUDIT_INDEX_SNAPSHOT: "1" });
56
+ expect(result.code).toBe(badIndex ? 1 : 0);
57
+ if (badIndex) expect(result.text).toContain(`string-dup-file ${SOURCE} "duplicate value" 2`);
58
+ else expect(result.text).toContain("no violations across 1 file(s)");
59
+ });
60
+
61
+ test("explicit dispatch checks working bytes and forwards only the named file", async () => {
62
+ const project = newRepository();
63
+ await staged(project, SOURCE, CLEAN);
64
+ await staged(project, "lib/unrelated.ts", DUPLICATE);
65
+ await Bun.write(join(project, SOURCE), NUMERIC);
66
+ const result = scan(project, "dispatch/write_any.sh", [SOURCE]);
67
+ expect(result.code).toBe(1);
68
+ expect(result.text).toContain(`ANY DISPATCH — 1 file(s)`);
69
+ const receipt = result.text.match(/UFS.*see (.+receipt\.log)/)?.[1];
70
+ expect(receipt).toBeDefined();
71
+ const details = await Bun.file(receipt ?? "").text();
72
+ expect(details).toContain(`numeric-suspect ${SOURCE}:1 ${SECONDS_PER_MINUTE}`);
73
+ expect(details).not.toContain("unrelated.ts");
74
+ });
75
+
76
+ test("successful concurrent dispatch receipts have distinct readable private owners", async () => {
77
+ const project = newRepository();
78
+ await staged(project, SOURCE, CLEAN);
79
+ const receiptsRoot = temporaryDirectory();
80
+ const commands = Array.from({ length: CONCURRENT_RECEIPTS }, () => Bun.spawn(["bash", join(ROOT, "dispatch/write_any.sh"), SOURCE], {
81
+ cwd: project, env: { ...UNSCOPED_ENVIRONMENT, TMPDIR: receiptsRoot }, stdout: "pipe", stderr: "pipe",
82
+ }));
83
+ const outputs = await Promise.all(commands.map(async (command) => {
84
+ const output = await new Response(command.stdout).text();
85
+ const error = await new Response(command.stderr).text();
86
+ expect(await command.exited).toBe(0);
87
+ return output + error;
88
+ }));
89
+ const receipts = outputs.flatMap((output) => Array.from(output.matchAll(/receipt: (.+receipt\.log)/g), (match) => match[1] ?? ""));
90
+ expect(receipts).toHaveLength(6);
91
+ expect(new Set(receipts).size).toBe(receipts.length);
92
+ for (const path of receipts) {
93
+ expect(await Bun.file(path).exists()).toBe(true);
94
+ expect(await Bun.file(path).text()).toMatch(/OK:/);
95
+ expect(statSync(dirname(path)).mode & PERMISSION_BITS).toBe(PRIVATE_DIRECTORY_MODE);
96
+ }
97
+ });
98
+
99
+ test.each([true, false])("staged Rust aliases come from the same index with alias present %s", async (hasAlias) => {
100
+ const project = newRepository();
101
+ await staged(project, MANIFEST, '[package]\nname = "runtime"\nversion = "0.0.0"\n');
102
+ await staged(project, RUST_SOURCE, ERROR_TYPE);
103
+ await staged(project, ALIAS_SOURCE, hasAlias ? OWNED_ALIAS : "// no alias\n");
104
+ await Bun.write(join(project, ALIAS_SOURCE), hasAlias ? "// no alias\n" : OWNED_ALIAS);
105
+ const result = scan(project, "audits/rust-error.sh", [STAGED]);
106
+ expect(result.code).toBe(hasAlias ? 0 : 1);
107
+ expect(result.text).toContain(`missing-result-alias=${hasAlias ? 0 : 1}`);
108
+ });
109
+
110
+ test.each([true, false])("single-line initializers outside src need cleanup only when allocating %s", async (allocates) => {
111
+ const project = newRepository();
112
+ await staged(project, ZIG_SOURCE, allocates ? ALLOCATING_INIT : VALUE_INIT);
113
+ const result = scan(project, "audits/deinit-pairs.sh", [STAGED]);
114
+ expect(result.code).toBe(allocates ? 1 : 0);
115
+ expect(result.text).toContain(allocates ? "requires cleanup (allocates) but no cleanup method" : "0 require cleanup, 0 unpaired");
116
+ });
117
+
118
+ test.each([true, false])("managed paths parse structurally with pretty configuration %s", async (pretty) => {
119
+ const project = newRepository();
120
+ await staged(project, SOURCE, DUPLICATE);
121
+ await staged(project, CONFIG, JSON.stringify({ managed: [], arbitrary: SOURCE }, null, pretty ? 2 : undefined));
122
+ const result = scan(project, "dispatch/write_any.sh", [STAGED]);
123
+ expect(result.code).toBe(1);
124
+ expect(result.text).toContain("ANY DISPATCH — 1 file(s)");
125
+ expect(result.text).toContain("UFS");
126
+ });
127
+
128
+ test.each(["{", '{"managed":"src/code.ts"}', '{"managed":[false]}'])("malformed managed metadata refuses before a clean verdict: %s", async (content) => {
129
+ const project = newRepository();
130
+ await staged(project, SOURCE, CLEAN);
131
+ await staged(project, CONFIG, content);
132
+ const result = scan(project, "dispatch/write_any.sh", [STAGED]);
133
+ expect(result.code).toBe(2);
134
+ expect(result.text).not.toContain("all dispatched gates pass");
135
+ });
136
+
137
+ test("staged renames are checked and deleted paths are skipped", async () => {
138
+ const project = newRepository();
139
+ const before = "lib/old.ts";
140
+ await staged(project, before, CLEAN);
141
+ git(project, COMMIT, "-qm", "test: seed rename");
142
+ git(project, "mv", before, SOURCE);
143
+ await staged(project, SOURCE, DUPLICATE);
144
+ const bad = scan(project, "audits/ufs.sh", [STAGED]);
145
+ expect(bad.code).toBe(1);
146
+ expect(bad.text).toContain(`string-dup-file ${SOURCE}`);
147
+ git(project, "rm", "-f", SOURCE);
148
+ const deleted = scan(project, "audits/ufs.sh", [STAGED]);
149
+ expect(deleted.code).toBe(0);
150
+ expect(deleted.text).toContain("no source files in scope");
151
+ });
152
+
153
+ test.each(SCANNERS)("%s preserves help and rejects unknown or mixed options", (leaf) => {
154
+ const project = newRepository();
155
+ expect(scan(project, `audits/${leaf}`, ["--help"]).text).toContain("usage:");
156
+ expect(scan(project, `audits/${leaf}`, ["--help"]).code).toBe(0);
157
+ expect(scan(project, `audits/${leaf}`, ["--invalid"]).code).toBe(2);
158
+ const mixed = scan(project, `audits/${leaf}`, [STAGED, SOURCE]);
159
+ expect(mixed.code).toBe(2);
160
+ expect(mixed.text).toContain("cannot be mixed");
161
+ });
162
+
163
+ test("inherited SCOPE defaults select the index and ignore unstaged bytes", async () => {
164
+ const project = newRepository();
165
+ await staged(project, SOURCE, CLEAN);
166
+ await Bun.write(join(project, SOURCE), NUMERIC);
167
+ const scoped = scan(project, "audits/ufs.sh", [], { SCOPE: "staged" });
168
+ expect(scoped.code).toBe(0);
169
+ expect(scoped.text).toContain("no violations across 1 file(s)");
170
+ expect(scan(project, "audits/ufs.sh", [ALL]).code).toBe(1);
171
+ });
172
+
173
+ test.each([true, false])("diff marker checks grade staged quoted paths with staged violation %s", async (badIndex) => {
174
+ const project = newRepository();
175
+ await staged(project, SOURCE, badIndex ? MARKED : CLEAN);
176
+ await Bun.write(join(project, SOURCE), badIndex ? CLEAN : MARKED);
177
+ const result = scan(project, "audits/msid-ui.sh", [STAGED]);
178
+ expect(result.code).toBe(badIndex ? 1 : 0);
179
+ expect(result.text).toContain(badIndex ? `MS-ID ${SOURCE}: // ${MARKER}` : "0 hits");
180
+ });
181
+
182
+ test("diff marker explicit files use working bytes and missing comparisons refuse", async () => {
183
+ const project = newRepository();
184
+ await staged(project, SOURCE, CLEAN);
185
+ await Bun.write(join(project, SOURCE), MARKED);
186
+ expect(scan(project, "audits/msid-ui.sh", [SOURCE]).text).toContain(`MS-ID ${SOURCE}`);
187
+ const missing = scan(project, "audits/msid-ui.sh", [DIFF], { BASE: "missing-revision" });
188
+ expect(missing.code).toBe(2);
189
+ expect(missing.text).toContain("comparison revision is missing: missing-revision");
190
+ });
191
+
192
+ test("indexed source links cannot read outside their owned snapshot", async () => {
193
+ const project = newRepository();
194
+ const external = join(temporaryDirectory(), "owner.ts");
195
+ await Bun.write(external, DUPLICATE);
196
+ symlinkSync(external, join(project, "source.ts"));
197
+ git(project, ADD, ".");
198
+ const result = scan(project, "audits/ufs.sh", [STAGED]);
199
+ expect(result.code).toBe(2);
200
+ expect(result.text).toContain("unsafe indexed symbolic link: source.ts");
201
+ expect(await Bun.file(external).text()).toBe(DUPLICATE);
202
+ });