@de-otio/repo-aegis-core 0.2.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 (191) hide show
  1. package/dist/age.d.ts +32 -0
  2. package/dist/age.d.ts.map +1 -0
  3. package/dist/age.js +98 -0
  4. package/dist/age.js.map +1 -0
  5. package/dist/audit-log.d.ts +50 -0
  6. package/dist/audit-log.d.ts.map +1 -0
  7. package/dist/audit-log.js +183 -0
  8. package/dist/audit-log.js.map +1 -0
  9. package/dist/audit-log.test.d.ts +2 -0
  10. package/dist/audit-log.test.d.ts.map +1 -0
  11. package/dist/audit-log.test.js +181 -0
  12. package/dist/audit-log.test.js.map +1 -0
  13. package/dist/deny-set.d.ts +43 -0
  14. package/dist/deny-set.d.ts.map +1 -0
  15. package/dist/deny-set.js +165 -0
  16. package/dist/deny-set.js.map +1 -0
  17. package/dist/deny-set.test.d.ts +2 -0
  18. package/dist/deny-set.test.d.ts.map +1 -0
  19. package/dist/deny-set.test.js +155 -0
  20. package/dist/deny-set.test.js.map +1 -0
  21. package/dist/exceptions.d.ts +96 -0
  22. package/dist/exceptions.d.ts.map +1 -0
  23. package/dist/exceptions.js +143 -0
  24. package/dist/exceptions.js.map +1 -0
  25. package/dist/exit-codes.d.ts +4 -0
  26. package/dist/exit-codes.d.ts.map +1 -0
  27. package/dist/exit-codes.js +6 -0
  28. package/dist/exit-codes.js.map +1 -0
  29. package/dist/first-touch.d.ts +57 -0
  30. package/dist/first-touch.d.ts.map +1 -0
  31. package/dist/first-touch.js +112 -0
  32. package/dist/first-touch.js.map +1 -0
  33. package/dist/import-graph.test.d.ts +2 -0
  34. package/dist/import-graph.test.d.ts.map +1 -0
  35. package/dist/import-graph.test.js +210 -0
  36. package/dist/import-graph.test.js.map +1 -0
  37. package/dist/index.d.ts +37 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +68 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/lock.d.ts +22 -0
  42. package/dist/lock.d.ts.map +1 -0
  43. package/dist/lock.js +86 -0
  44. package/dist/lock.js.map +1 -0
  45. package/dist/lock.test.d.ts +2 -0
  46. package/dist/lock.test.d.ts.map +1 -0
  47. package/dist/lock.test.js +125 -0
  48. package/dist/lock.test.js.map +1 -0
  49. package/dist/paths.d.ts +22 -0
  50. package/dist/paths.d.ts.map +1 -0
  51. package/dist/paths.js +46 -0
  52. package/dist/paths.js.map +1 -0
  53. package/dist/paths.test.d.ts +2 -0
  54. package/dist/paths.test.d.ts.map +1 -0
  55. package/dist/paths.test.js +78 -0
  56. package/dist/paths.test.js.map +1 -0
  57. package/dist/redaction.d.ts +29 -0
  58. package/dist/redaction.d.ts.map +1 -0
  59. package/dist/redaction.js +48 -0
  60. package/dist/redaction.js.map +1 -0
  61. package/dist/redaction.test.d.ts +2 -0
  62. package/dist/redaction.test.d.ts.map +1 -0
  63. package/dist/redaction.test.js +67 -0
  64. package/dist/redaction.test.js.map +1 -0
  65. package/dist/regex-safety.d.ts +87 -0
  66. package/dist/regex-safety.d.ts.map +1 -0
  67. package/dist/regex-safety.js +322 -0
  68. package/dist/regex-safety.js.map +1 -0
  69. package/dist/regex-safety.test.d.ts +2 -0
  70. package/dist/regex-safety.test.d.ts.map +1 -0
  71. package/dist/regex-safety.test.js +149 -0
  72. package/dist/regex-safety.test.js.map +1 -0
  73. package/dist/registry-mutate.d.ts +35 -0
  74. package/dist/registry-mutate.d.ts.map +1 -0
  75. package/dist/registry-mutate.js +149 -0
  76. package/dist/registry-mutate.js.map +1 -0
  77. package/dist/registry-mutate.test.d.ts +2 -0
  78. package/dist/registry-mutate.test.d.ts.map +1 -0
  79. package/dist/registry-mutate.test.js +96 -0
  80. package/dist/registry-mutate.test.js.map +1 -0
  81. package/dist/registry.d.ts +64 -0
  82. package/dist/registry.d.ts.map +1 -0
  83. package/dist/registry.js +120 -0
  84. package/dist/registry.js.map +1 -0
  85. package/dist/registry.test.d.ts +2 -0
  86. package/dist/registry.test.d.ts.map +1 -0
  87. package/dist/registry.test.js +316 -0
  88. package/dist/registry.test.js.map +1 -0
  89. package/dist/remote-url.d.ts +18 -0
  90. package/dist/remote-url.d.ts.map +1 -0
  91. package/dist/remote-url.js +66 -0
  92. package/dist/remote-url.js.map +1 -0
  93. package/dist/remote-url.test.d.ts +2 -0
  94. package/dist/remote-url.test.d.ts.map +1 -0
  95. package/dist/remote-url.test.js +116 -0
  96. package/dist/remote-url.test.js.map +1 -0
  97. package/dist/render.d.ts +54 -0
  98. package/dist/render.d.ts.map +1 -0
  99. package/dist/render.js +182 -0
  100. package/dist/render.js.map +1 -0
  101. package/dist/render.test.d.ts +2 -0
  102. package/dist/render.test.d.ts.map +1 -0
  103. package/dist/render.test.js +152 -0
  104. package/dist/render.test.js.map +1 -0
  105. package/dist/repo.d.ts +40 -0
  106. package/dist/repo.d.ts.map +1 -0
  107. package/dist/repo.js +214 -0
  108. package/dist/repo.js.map +1 -0
  109. package/dist/repo.test.d.ts +2 -0
  110. package/dist/repo.test.d.ts.map +1 -0
  111. package/dist/repo.test.js +234 -0
  112. package/dist/repo.test.js.map +1 -0
  113. package/dist/scan.d.ts +103 -0
  114. package/dist/scan.d.ts.map +1 -0
  115. package/dist/scan.js +436 -0
  116. package/dist/scan.js.map +1 -0
  117. package/dist/scan.test.d.ts +2 -0
  118. package/dist/scan.test.d.ts.map +1 -0
  119. package/dist/scan.test.js +437 -0
  120. package/dist/scan.test.js.map +1 -0
  121. package/dist/schemas.d.ts +50 -0
  122. package/dist/schemas.d.ts.map +1 -0
  123. package/dist/schemas.js +190 -0
  124. package/dist/schemas.js.map +1 -0
  125. package/dist/secret-markers.d.ts +34 -0
  126. package/dist/secret-markers.d.ts.map +1 -0
  127. package/dist/secret-markers.js +118 -0
  128. package/dist/secret-markers.js.map +1 -0
  129. package/dist/secret-markers.test.d.ts +2 -0
  130. package/dist/secret-markers.test.d.ts.map +1 -0
  131. package/dist/secret-markers.test.js +154 -0
  132. package/dist/secret-markers.test.js.map +1 -0
  133. package/dist/trust-boundary.d.ts +33 -0
  134. package/dist/trust-boundary.d.ts.map +1 -0
  135. package/dist/trust-boundary.js +77 -0
  136. package/dist/trust-boundary.js.map +1 -0
  137. package/dist/trust-boundary.test.d.ts +2 -0
  138. package/dist/trust-boundary.test.d.ts.map +1 -0
  139. package/dist/trust-boundary.test.js +170 -0
  140. package/dist/trust-boundary.test.js.map +1 -0
  141. package/dist/types.d.ts +47 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +8 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/working-tree.d.ts +38 -0
  146. package/dist/working-tree.d.ts.map +1 -0
  147. package/dist/working-tree.js +133 -0
  148. package/dist/working-tree.js.map +1 -0
  149. package/dist/working-tree.test.d.ts +2 -0
  150. package/dist/working-tree.test.d.ts.map +1 -0
  151. package/dist/working-tree.test.js +162 -0
  152. package/dist/working-tree.test.js.map +1 -0
  153. package/package.json +40 -0
  154. package/src/age.ts +113 -0
  155. package/src/audit-log.test.ts +222 -0
  156. package/src/audit-log.ts +215 -0
  157. package/src/deny-set.test.ts +208 -0
  158. package/src/deny-set.ts +231 -0
  159. package/src/exceptions.ts +134 -0
  160. package/src/exit-codes.ts +5 -0
  161. package/src/first-touch.ts +172 -0
  162. package/src/import-graph.test.ts +239 -0
  163. package/src/index.ts +191 -0
  164. package/src/lock.test.ts +151 -0
  165. package/src/lock.ts +88 -0
  166. package/src/paths.test.ts +94 -0
  167. package/src/paths.ts +55 -0
  168. package/src/redaction.test.ts +81 -0
  169. package/src/redaction.ts +49 -0
  170. package/src/regex-safety.test.ts +194 -0
  171. package/src/regex-safety.ts +349 -0
  172. package/src/registry-mutate.test.ts +134 -0
  173. package/src/registry-mutate.ts +185 -0
  174. package/src/registry.test.ts +460 -0
  175. package/src/registry.ts +178 -0
  176. package/src/remote-url.test.ts +121 -0
  177. package/src/remote-url.ts +78 -0
  178. package/src/render.test.ts +206 -0
  179. package/src/render.ts +215 -0
  180. package/src/repo.test.ts +275 -0
  181. package/src/repo.ts +245 -0
  182. package/src/scan.test.ts +580 -0
  183. package/src/scan.ts +531 -0
  184. package/src/schemas.ts +207 -0
  185. package/src/secret-markers.test.ts +183 -0
  186. package/src/secret-markers.ts +145 -0
  187. package/src/trust-boundary.test.ts +198 -0
  188. package/src/trust-boundary.ts +98 -0
  189. package/src/types.ts +55 -0
  190. package/src/working-tree.test.ts +193 -0
  191. package/src/working-tree.ts +130 -0
@@ -0,0 +1,185 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ //
4
+ // `addMarkerPattern` / `addMarkerPatterns` (P2-B-1) — core helper for
5
+ // programmatically appending validated regex patterns to an
6
+ // engagement's markers.
7
+ //
8
+ // [SEC M-3] Lock scope covers the entire load-modify-write-render
9
+ // cycle so concurrent callers (parallel `suggest-markers` runs against
10
+ // different engagements) cannot lose updates or leave the rendered
11
+ // markers stale relative to the registry.
12
+
13
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
14
+ import { parseDocument, YAMLSeq, YAMLMap, Scalar, isMap } from "yaml";
15
+ import { registryPath as defaultRegistryPath } from "./paths.js";
16
+ import { loadRegistry } from "./registry.js";
17
+ import { renderMarkers } from "./render.js";
18
+ import { withLockSync } from "./lock.js";
19
+ import { validatePattern } from "./regex-safety.js";
20
+ import { appendAuditRecord } from "./audit-log.js";
21
+ import {
22
+ EngagementNotFoundError,
23
+ PatternValidationError,
24
+ } from "./exceptions.js";
25
+
26
+ export interface AddMarkerPatternOptions {
27
+ /** Override the registry path (defaults to ~/.config/repo-aegis/engagements.yaml). */
28
+ registryPath?: string;
29
+ /**
30
+ * When true, audit-log writes record the source the pattern came from
31
+ * (e.g. `"suggest-markers"`). Caller-provided so the caller's verb
32
+ * shows up in the trail. Default: `"manual"`.
33
+ */
34
+ source?: string;
35
+ }
36
+
37
+ export interface AddMarkerPatternResult {
38
+ added: string[];
39
+ skipped: string[];
40
+ rendered: { written: number; removed: number };
41
+ }
42
+
43
+ function findEngagementNode(doc: ReturnType<typeof parseDocument>, id: string): YAMLMap | null {
44
+ const seq = doc.get("engagements") as YAMLSeq | null;
45
+ if (!seq || !seq.items) return null;
46
+ for (const item of seq.items) {
47
+ if (!isMap(item)) continue;
48
+ const idNode = item.get("id");
49
+ if (typeof idNode === "string" && idNode === id) return item as YAMLMap;
50
+ if (idNode instanceof Scalar && idNode.value === id) return item as YAMLMap;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function readMarkers(node: YAMLMap): string[] {
56
+ const seq = node.get("markers") as YAMLSeq | null;
57
+ if (!seq || !seq.items) return [];
58
+ const out: string[] = [];
59
+ for (const item of seq.items) {
60
+ if (typeof item === "string") out.push(item);
61
+ else if (item instanceof Scalar && typeof item.value === "string")
62
+ out.push(item.value);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Append one or more validated regex patterns to an engagement's
69
+ * markers. Held under a single registry lock for the entire
70
+ * load-modify-write-render cycle (see [SEC M-3]). Idempotent: patterns
71
+ * already present are reported in `skipped`, not re-added.
72
+ *
73
+ * Throws:
74
+ * - `EngagementNotFoundError` if no engagement with that id exists.
75
+ * - `PatternValidationError` if any pattern fails `validatePattern`.
76
+ */
77
+ export function addMarkerPatterns(
78
+ engagementId: string,
79
+ patterns: string[],
80
+ opts: AddMarkerPatternOptions = {},
81
+ ): AddMarkerPatternResult {
82
+ const path = opts.registryPath ?? defaultRegistryPath();
83
+ const source = opts.source ?? "manual";
84
+
85
+ // Validate every pattern up front. Fail closed before touching the
86
+ // registry on the first bad pattern.
87
+ const invalid: Array<{ pattern: string; reason: string }> = [];
88
+ for (const p of patterns) {
89
+ const v = validatePattern(p);
90
+ if (!v.ok) {
91
+ invalid.push({ pattern: p, reason: v.reason ?? "unknown" });
92
+ }
93
+ }
94
+ if (invalid.length > 0) {
95
+ throw new PatternValidationError(
96
+ invalid.map(i => ({ ...i, engagementId })),
97
+ );
98
+ }
99
+
100
+ // [SEC M-3] Lock spans the entire read-modify-write-render cycle.
101
+ const result = withLockSync(() => {
102
+ if (!existsSync(path)) {
103
+ throw new Error(`registry not found at ${path}`);
104
+ }
105
+ const raw = readFileSync(path, "utf8");
106
+ const doc = parseDocument(raw);
107
+
108
+ const node = findEngagementNode(doc, engagementId);
109
+ if (node === null) {
110
+ throw new EngagementNotFoundError(engagementId);
111
+ }
112
+
113
+ let seq = node.get("markers") as YAMLSeq | null;
114
+ if (!seq || !(seq instanceof YAMLSeq)) {
115
+ seq = new YAMLSeq();
116
+ node.set("markers", seq);
117
+ }
118
+
119
+ const existing = new Set(readMarkers(node));
120
+ const added: string[] = [];
121
+ const skipped: string[] = [];
122
+ for (const p of patterns) {
123
+ if (existing.has(p)) {
124
+ skipped.push(p);
125
+ continue;
126
+ }
127
+ seq.add(p);
128
+ existing.add(p);
129
+ added.push(p);
130
+ }
131
+
132
+ if (added.length > 0) {
133
+ writeFileSync(path, doc.toString(), { mode: 0o600 });
134
+ try {
135
+ chmodSync(path, 0o600);
136
+ } catch {
137
+ /* platform-restricted */
138
+ }
139
+ }
140
+
141
+ // Re-render markers regardless of whether new patterns landed:
142
+ // skipped duplicates still represent a successful no-op for the
143
+ // caller, and re-rendering keeps the markers/ output in sync if
144
+ // anything else mutated the registry between previous render and
145
+ // now (covered by the same lock).
146
+ const reg = loadRegistry(path);
147
+ const render = renderMarkers(reg);
148
+
149
+ return {
150
+ added,
151
+ skipped,
152
+ rendered: { written: render.written.length, removed: render.removed.length },
153
+ };
154
+ });
155
+
156
+ // Audit (best-effort). Records id + counts only — never the literal
157
+ // patterns themselves.
158
+ try {
159
+ appendAuditRecord({
160
+ action: "engagements-add-marker",
161
+ engagement: engagementId,
162
+ details: {
163
+ added: result.added.length,
164
+ skipped: result.skipped.length,
165
+ source,
166
+ },
167
+ });
168
+ } catch {
169
+ /* audit log must not break user-facing ops */
170
+ }
171
+
172
+ return result;
173
+ }
174
+
175
+ /**
176
+ * Convenience wrapper: append a single pattern. Same semantics as
177
+ * `addMarkerPatterns([pattern])`.
178
+ */
179
+ export function addMarkerPattern(
180
+ engagementId: string,
181
+ pattern: string,
182
+ opts: AddMarkerPatternOptions = {},
183
+ ): AddMarkerPatternResult {
184
+ return addMarkerPatterns(engagementId, [pattern], opts);
185
+ }
@@ -0,0 +1,460 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { describe, it, before, after } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import {
9
+ loadRegistry,
10
+ isActive,
11
+ resolveEngagement,
12
+ MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION,
13
+ } from "./registry.js";
14
+ import {
15
+ RegistryNotFoundError,
16
+ RegistryParseError,
17
+ RegistryEncryptedError,
18
+ } from "./exceptions.js";
19
+
20
+ let tmp: string;
21
+
22
+ before(() => {
23
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-registry-"));
24
+ });
25
+
26
+ after(() => {
27
+ rmSync(tmp, { recursive: true, force: true });
28
+ });
29
+
30
+ function writeYaml(name: string, body: string): string {
31
+ const p = join(tmp, name);
32
+ writeFileSync(p, body);
33
+ return p;
34
+ }
35
+
36
+ describe("loadRegistry", () => {
37
+ it("loads a minimal valid registry with alwaysBlock", () => {
38
+ const path = writeYaml(
39
+ "minimal.yaml",
40
+ `
41
+ always_block:
42
+ - PROJECT-CODENAME-ALPHA
43
+
44
+ engagements:
45
+ - id: customer-a
46
+ name: Customer A
47
+ markers:
48
+ - acme-corp
49
+ `,
50
+ );
51
+ const reg = loadRegistry(path);
52
+ assert.equal(reg.engagements.length, 1);
53
+ assert.equal(reg.engagements[0]!.id, "customer-a");
54
+ assert.equal(reg.alwaysBlock.length, 1);
55
+ assert.equal(reg.alwaysBlock[0], "PROJECT-CODENAME-ALPHA");
56
+ });
57
+
58
+ it("treats missing always_block as empty", () => {
59
+ const path = writeYaml(
60
+ "no-always.yaml",
61
+ `engagements:
62
+ - id: customer-a
63
+ name: Customer A
64
+ markers: [acme]`,
65
+ );
66
+ const reg = loadRegistry(path);
67
+ assert.equal(reg.alwaysBlock.length, 0);
68
+ });
69
+
70
+ it("throws RegistryNotFoundError when file missing", () => {
71
+ assert.throws(() => loadRegistry(join(tmp, "nonexistent.yaml")), RegistryNotFoundError);
72
+ });
73
+
74
+ it("throws RegistryEncryptedError when only <path>.age exists", () => {
75
+ const plain = join(tmp, "encrypted-only.yaml");
76
+ const cipher = `${plain}.age`;
77
+ writeFileSync(cipher, "age-encrypted-payload");
78
+ assert.throws(
79
+ () => loadRegistry(plain),
80
+ (err: unknown) =>
81
+ err instanceof RegistryEncryptedError &&
82
+ err.code === "REGISTRY_ENCRYPTED" &&
83
+ err.path === plain &&
84
+ err.ciphertextPath === cipher &&
85
+ /registry decrypt/.test(err.message),
86
+ );
87
+ rmSync(cipher);
88
+ });
89
+
90
+ it("prefers RegistryEncryptedError over RegistryNotFoundError when both states would apply", () => {
91
+ // i.e. plaintext absent + ciphertext present => encrypted error,
92
+ // not "not found". The agent gets a recoverable signal.
93
+ const plain = join(tmp, "encrypted-vs-missing.yaml");
94
+ writeFileSync(`${plain}.age`, "ciphertext");
95
+ assert.throws(() => loadRegistry(plain), RegistryEncryptedError);
96
+ rmSync(`${plain}.age`);
97
+ });
98
+
99
+ it("does not throw RegistryEncryptedError when plaintext exists alongside .age", () => {
100
+ // Defensive: if for any reason both files coexist, the plaintext
101
+ // wins (loadRegistry's whole job is to read the plaintext). The
102
+ // CLI's encrypt/decrypt flow ensures this doesn't happen, but the
103
+ // load path must not surprise callers.
104
+ const plain = writeYaml(
105
+ "both-exist.yaml",
106
+ `engagements:
107
+ - id: customer-a
108
+ name: Customer A
109
+ markers: [foo]`,
110
+ );
111
+ writeFileSync(`${plain}.age`, "ciphertext");
112
+ const reg = loadRegistry(plain);
113
+ assert.equal(reg.engagements.length, 1);
114
+ rmSync(`${plain}.age`);
115
+ });
116
+
117
+ it("throws RegistryParseError for invalid YAML", () => {
118
+ const path = writeYaml("invalid.yaml", "not: valid: yaml: at: all:");
119
+ assert.throws(() => loadRegistry(path), RegistryParseError);
120
+ });
121
+
122
+ it("throws when missing top-level engagements key", () => {
123
+ const path = writeYaml("missing-engagements.yaml", "always_block:\n - foo");
124
+ assert.throws(() => loadRegistry(path), RegistryParseError);
125
+ });
126
+
127
+ it("rejects engagement with id _always (reserved)", () => {
128
+ const path = writeYaml(
129
+ "reserved-id.yaml",
130
+ `engagements:
131
+ - id: _always
132
+ name: Always
133
+ markers: [foo]`,
134
+ );
135
+ assert.throws(() => loadRegistry(path), /reserved/);
136
+ });
137
+
138
+ it("requires markers to be a list", () => {
139
+ const path = writeYaml(
140
+ "no-markers.yaml",
141
+ `engagements:
142
+ - id: a
143
+ name: A`,
144
+ );
145
+ assert.throws(() => loadRegistry(path), /markers/);
146
+ });
147
+
148
+ it("requires always_block to be a list of strings", () => {
149
+ const path = writeYaml(
150
+ "bad-always.yaml",
151
+ `always_block: not_a_list
152
+ engagements: []`,
153
+ );
154
+ assert.throws(() => loadRegistry(path), RegistryParseError);
155
+ });
156
+
157
+ it("treats missing schemaVersion as version 1 (legacy)", () => {
158
+ const path = writeYaml(
159
+ "no-schema-version.yaml",
160
+ `engagements:
161
+ - id: customer-a
162
+ name: Customer A
163
+ markers: [foo]`,
164
+ );
165
+ const reg = loadRegistry(path);
166
+ assert.equal(reg.schemaVersion, 1);
167
+ });
168
+
169
+ it("accepts schemaVersion: 1", () => {
170
+ const path = writeYaml(
171
+ "schema-v1.yaml",
172
+ `schemaVersion: 1
173
+ engagements:
174
+ - id: customer-a
175
+ name: Customer A
176
+ markers: [foo]`,
177
+ );
178
+ const reg = loadRegistry(path);
179
+ assert.equal(reg.schemaVersion, 1);
180
+ });
181
+
182
+ it("rejects schemaVersion greater than max supported with an upgrade message", () => {
183
+ const path = writeYaml(
184
+ "schema-v99.yaml",
185
+ `schemaVersion: 99
186
+ engagements:
187
+ - id: customer-a
188
+ name: Customer A
189
+ markers: [foo]`,
190
+ );
191
+ assert.throws(
192
+ () => loadRegistry(path),
193
+ (err: unknown) =>
194
+ err instanceof RegistryParseError &&
195
+ /please upgrade/i.test(err.message) &&
196
+ /99/.test(err.message),
197
+ );
198
+ });
199
+
200
+ it("rejects non-numeric schemaVersion", () => {
201
+ const path = writeYaml(
202
+ "schema-bad.yaml",
203
+ `schemaVersion: "not-a-number"
204
+ engagements:
205
+ - id: customer-a
206
+ name: Customer A
207
+ markers: [foo]`,
208
+ );
209
+ assert.throws(() => loadRegistry(path), RegistryParseError);
210
+ });
211
+
212
+ it("MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION is the current pinned version", () => {
213
+ // Sanity guard: bumping this constant is intentional and should be
214
+ // accompanied by a migration plan and updated tests.
215
+ assert.equal(MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION, 2);
216
+ });
217
+
218
+ // -- schemaVersion 2: personalOrgs + engagements[*].githubOrgs --
219
+
220
+ it("v1 file (no schemaVersion, no personalOrgs/githubOrgs) loads with empty defaults", () => {
221
+ const path = writeYaml(
222
+ "v1-defaults.yaml",
223
+ `engagements:
224
+ - id: customer-a
225
+ name: Customer A
226
+ markers: [foo]`,
227
+ );
228
+ const reg = loadRegistry(path);
229
+ assert.equal(reg.schemaVersion, 1);
230
+ assert.deepEqual(reg.personalOrgs, []);
231
+ assert.equal(reg.engagements[0]!.githubOrgs, undefined);
232
+ });
233
+
234
+ it("loads schemaVersion 2 with personalOrgs and engagements[*].githubOrgs", () => {
235
+ const path = writeYaml(
236
+ "v2-full.yaml",
237
+ `schemaVersion: 2
238
+ personalOrgs:
239
+ - my-handle
240
+ - my-oss-org
241
+ engagements:
242
+ - id: foo-corp
243
+ name: Foo Corp
244
+ githubOrgs: [foo-corp, foo-corp-archived]
245
+ markers: [foo]
246
+ - id: bar-co
247
+ name: Bar Co
248
+ githubOrgs: [bar-co]
249
+ markers: [bar]`,
250
+ );
251
+ const reg = loadRegistry(path);
252
+ assert.equal(reg.schemaVersion, 2);
253
+ assert.deepEqual(reg.personalOrgs, ["my-handle", "my-oss-org"]);
254
+ assert.deepEqual(reg.engagements[0]!.githubOrgs, ["foo-corp", "foo-corp-archived"]);
255
+ assert.deepEqual(reg.engagements[1]!.githubOrgs, ["bar-co"]);
256
+ });
257
+
258
+ it("rejects org name with uppercase characters", () => {
259
+ const path = writeYaml(
260
+ "v2-uppercase.yaml",
261
+ `schemaVersion: 2
262
+ personalOrgs: [Bad-Org]
263
+ engagements: []`,
264
+ );
265
+ assert.throws(
266
+ () => loadRegistry(path),
267
+ (err: unknown) =>
268
+ err instanceof RegistryParseError && /lowercase/i.test(err.message),
269
+ );
270
+ });
271
+
272
+ it("rejects org name with leading hyphen", () => {
273
+ const path = writeYaml(
274
+ "v2-leading-hyphen.yaml",
275
+ `schemaVersion: 2
276
+ engagements:
277
+ - id: foo
278
+ name: Foo
279
+ githubOrgs: ["-bad"]
280
+ markers: []`,
281
+ );
282
+ assert.throws(() => loadRegistry(path), RegistryParseError);
283
+ });
284
+
285
+ it("rejects org name with whitespace", () => {
286
+ const path = writeYaml(
287
+ "v2-whitespace.yaml",
288
+ `schemaVersion: 2
289
+ engagements:
290
+ - id: foo
291
+ name: Foo
292
+ githubOrgs: ["bad org"]
293
+ markers: []`,
294
+ );
295
+ assert.throws(() => loadRegistry(path), RegistryParseError);
296
+ });
297
+
298
+ it("rejects empty string in githubOrgs", () => {
299
+ const path = writeYaml(
300
+ "v2-empty.yaml",
301
+ `schemaVersion: 2
302
+ engagements:
303
+ - id: foo
304
+ name: Foo
305
+ githubOrgs: [""]
306
+ markers: []`,
307
+ );
308
+ assert.throws(() => loadRegistry(path), RegistryParseError);
309
+ });
310
+
311
+ it("rejects same org appearing in personalOrgs and engagements[*].githubOrgs (disjointness)", () => {
312
+ const path = writeYaml(
313
+ "v2-overlap.yaml",
314
+ `schemaVersion: 2
315
+ personalOrgs: [my-handle]
316
+ engagements:
317
+ - id: foo
318
+ name: Foo
319
+ githubOrgs: [my-handle]
320
+ markers: []`,
321
+ );
322
+ assert.throws(
323
+ () => loadRegistry(path),
324
+ (err: unknown) =>
325
+ err instanceof RegistryParseError &&
326
+ /personalOrgs/.test(err.message) &&
327
+ /my-handle/.test(err.message) &&
328
+ /mutually exclusive/.test(err.message),
329
+ );
330
+ });
331
+
332
+ it("rejects same org listed in two different engagements' githubOrgs (uniqueness)", () => {
333
+ const path = writeYaml(
334
+ "v2-dup.yaml",
335
+ `schemaVersion: 2
336
+ engagements:
337
+ - id: foo
338
+ name: Foo
339
+ githubOrgs: [shared-org]
340
+ markers: []
341
+ - id: bar
342
+ name: Bar
343
+ githubOrgs: [shared-org]
344
+ markers: []`,
345
+ );
346
+ assert.throws(
347
+ () => loadRegistry(path),
348
+ (err: unknown) =>
349
+ err instanceof RegistryParseError &&
350
+ /shared-org/.test(err.message) &&
351
+ /at most one engagement/.test(err.message) &&
352
+ /foo/.test(err.message),
353
+ );
354
+ });
355
+
356
+ it("rejects duplicate entries inside personalOrgs", () => {
357
+ const path = writeYaml(
358
+ "v2-personal-dup.yaml",
359
+ `schemaVersion: 2
360
+ personalOrgs: [me, me]
361
+ engagements: []`,
362
+ );
363
+ assert.throws(
364
+ () => loadRegistry(path),
365
+ (err: unknown) =>
366
+ err instanceof RegistryParseError && /duplicate/i.test(err.message),
367
+ );
368
+ });
369
+
370
+ it("rejects schemaVersion 3 with the existing 'newer than supported' message", () => {
371
+ const path = writeYaml(
372
+ "v3.yaml",
373
+ `schemaVersion: 3
374
+ engagements:
375
+ - id: customer-a
376
+ name: Customer A
377
+ markers: [foo]`,
378
+ );
379
+ assert.throws(
380
+ () => loadRegistry(path),
381
+ (err: unknown) =>
382
+ err instanceof RegistryParseError &&
383
+ /please upgrade/i.test(err.message) &&
384
+ /3/.test(err.message),
385
+ );
386
+ });
387
+ });
388
+
389
+ describe("isActive", () => {
390
+ it("returns true when ended is null/undefined", () => {
391
+ assert.equal(isActive({ id: "a", name: "A", markers: [] }), true);
392
+ assert.equal(isActive({ id: "a", name: "A", ended: null, markers: [] }), true);
393
+ });
394
+
395
+ it("returns true within retention window", () => {
396
+ const recent = new Date();
397
+ recent.setMonth(recent.getMonth() - 6);
398
+ assert.equal(
399
+ isActive({ id: "a", name: "A", ended: recent.toISOString().slice(0, 10), markers: [] }, 12),
400
+ true,
401
+ );
402
+ });
403
+
404
+ it("returns false past retention window", () => {
405
+ const old = new Date();
406
+ old.setFullYear(old.getFullYear() - 2);
407
+ assert.equal(
408
+ isActive({ id: "a", name: "A", ended: old.toISOString().slice(0, 10), markers: [] }, 12),
409
+ false,
410
+ );
411
+ });
412
+
413
+ it("treats malformed dates as active (conservative)", () => {
414
+ assert.equal(isActive({ id: "a", name: "A", ended: "not-a-date", markers: [] }), true);
415
+ });
416
+ });
417
+
418
+ describe("resolveEngagement", () => {
419
+ const reg = {
420
+ engagements: [
421
+ { id: "customer-a-2025", name: "Customer A", markers: [] },
422
+ { id: "customer-b-2024", name: "Customer B", markers: [] },
423
+ { id: "customer-c", name: "Acme Corp", markers: [] },
424
+ ],
425
+ alwaysBlock: [],
426
+ schemaVersion: 1,
427
+ };
428
+
429
+ it("matches by exact id", () => {
430
+ const r = resolveEngagement(reg, "customer-a-2025");
431
+ assert.equal(r.match?.id, "customer-a-2025");
432
+ });
433
+
434
+ it("matches by exact name (case-insensitive)", () => {
435
+ const r = resolveEngagement(reg, "customer a");
436
+ assert.equal(r.match?.id, "customer-a-2025");
437
+ });
438
+
439
+ it("matches by fuzzy substring on id", () => {
440
+ const r = resolveEngagement(reg, "2024");
441
+ assert.equal(r.match?.id, "customer-b-2024");
442
+ });
443
+
444
+ it("returns no match with multiple candidates", () => {
445
+ const r = resolveEngagement(reg, "customer");
446
+ assert.equal(r.match, null);
447
+ assert.ok(r.candidates.length >= 2);
448
+ });
449
+
450
+ it("returns no match and empty candidates when nothing matches", () => {
451
+ const r = resolveEngagement(reg, "nonexistent");
452
+ assert.equal(r.match, null);
453
+ assert.equal(r.candidates.length, 0);
454
+ });
455
+
456
+ it("matches by name when name is distinct from id", () => {
457
+ const r = resolveEngagement(reg, "acme corp");
458
+ assert.equal(r.match?.id, "customer-c");
459
+ });
460
+ });