@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
package/src/render.ts ADDED
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { mkdirSync, writeFileSync, readdirSync, unlinkSync, chmodSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import {
6
+ markersDir as defaultMarkersDir,
7
+ flatMarkersPath as defaultFlatMarkersPath,
8
+ repoAegisHome,
9
+ } from "./paths.js";
10
+ import { isActive, type Registry, type Engagement } from "./registry.js";
11
+ import { ALWAYS_FILE_STEM } from "./deny-set.js";
12
+ import { validatePatterns } from "./regex-safety.js";
13
+ import { PatternValidationError } from "./exceptions.js";
14
+
15
+ /**
16
+ * Format version of marker files written by {@link renderMarkers}. Emitted
17
+ * as the second header line (`; repo-aegis-marker-format: <N>`) of every
18
+ * generated marker file. The deny-set parser ignores `;`-comment lines, so
19
+ * older readers tolerate the field; future readers can branch on it. Per
20
+ * design B14: writers must never lower this version.
21
+ */
22
+ export const MARKER_FORMAT_VERSION = 1;
23
+
24
+ export interface RenderOptions {
25
+ dryRun?: boolean;
26
+ markersDir?: string;
27
+ flatPath?: string;
28
+ retentionMonths?: number;
29
+ /**
30
+ * If true (default), all marker patterns across the registry are validated
31
+ * before any file is written. Patterns failing validation cause render to
32
+ * throw `PatternValidationError` without writing.
33
+ */
34
+ validatePatterns?: boolean;
35
+ }
36
+
37
+ export interface RenderedFile {
38
+ path: string;
39
+ engagementId: string;
40
+ patternCount: number;
41
+ }
42
+
43
+ export interface RenderResult {
44
+ written: RenderedFile[];
45
+ removed: string[];
46
+ flat: string | null;
47
+ invalidPatterns: { engagementId: string; pattern: string; reason: string }[];
48
+ }
49
+
50
+ /**
51
+ * Generate per-engagement marker files from the registry.
52
+ *
53
+ * Behaviour:
54
+ * 1. Validate every pattern across all engagements + alwaysBlock. If any
55
+ * fail validation, throw `PatternValidationError` and write nothing.
56
+ * 2. Write `markers/_always.txt` from `reg.alwaysBlock`.
57
+ * 3. For each engagement where `isActive(e, retentionMonths)` is true,
58
+ * write `markers/<id>.txt`.
59
+ * 4. Compare existing marker files against the new set; delete files
60
+ * whose stem is not in the new set.
61
+ * 5. Write the flat union `markers.txt` for back-compat.
62
+ *
63
+ * All files are written with mode 0600. The markers directory is created
64
+ * with mode 0700.
65
+ */
66
+ export function renderMarkers(reg: Registry, opts: RenderOptions = {}): RenderResult {
67
+ const dir = opts.markersDir ?? defaultMarkersDir();
68
+ const flatPath = opts.flatPath ?? defaultFlatMarkersPath();
69
+ const retentionMonths = opts.retentionMonths ?? 12;
70
+ const dryRun = opts.dryRun ?? false;
71
+ const doValidate = opts.validatePatterns ?? true;
72
+
73
+ const invalidPatterns: { engagementId: string; pattern: string; reason: string }[] = [];
74
+ if (doValidate) {
75
+ // Strict mode: subprocess-backed validation that can be preemptively
76
+ // killed on catastrophic-backtracking patterns. Render is a manual,
77
+ // infrequent operation; the ~50-200ms spawn overhead is acceptable.
78
+ for (const e of reg.engagements) {
79
+ const r = validatePatterns(e.markers, { strict: true });
80
+ for (const inv of r.invalid) {
81
+ invalidPatterns.push({ engagementId: e.id, pattern: inv.pattern, reason: inv.reason });
82
+ }
83
+ }
84
+ const alwaysR = validatePatterns(reg.alwaysBlock, { strict: true });
85
+ for (const inv of alwaysR.invalid) {
86
+ invalidPatterns.push({
87
+ engagementId: ALWAYS_FILE_STEM,
88
+ pattern: inv.pattern,
89
+ reason: inv.reason,
90
+ });
91
+ }
92
+ if (invalidPatterns.length > 0) {
93
+ throw new PatternValidationError(
94
+ invalidPatterns.map(p => ({
95
+ pattern: p.pattern,
96
+ reason: p.reason,
97
+ engagementId: p.engagementId,
98
+ })),
99
+ );
100
+ }
101
+ }
102
+
103
+ const written: RenderedFile[] = [];
104
+ const targetStems = new Set<string>([ALWAYS_FILE_STEM]);
105
+
106
+ // Always-block file (even if alwaysBlock is empty: ensures the file exists,
107
+ // simplifying downstream "compute deny set" logic).
108
+ if (!dryRun) {
109
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
110
+ try {
111
+ chmodSync(dir, 0o700);
112
+ } catch {
113
+ /* permission may be platform-restricted */
114
+ }
115
+ }
116
+ const alwaysPath = join(dir, `${ALWAYS_FILE_STEM}.txt`);
117
+ if (!dryRun) writeMarkerFile(alwaysPath, ALWAYS_FILE_STEM, reg.alwaysBlock);
118
+ written.push({
119
+ path: alwaysPath,
120
+ engagementId: ALWAYS_FILE_STEM,
121
+ patternCount: reg.alwaysBlock.length,
122
+ });
123
+
124
+ for (const e of reg.engagements) {
125
+ if (!isActive(e, retentionMonths)) continue;
126
+ targetStems.add(e.id);
127
+ const path = join(dir, `${e.id}.txt`);
128
+ if (!dryRun) writeMarkerFile(path, e.id, e.markers, e.name);
129
+ written.push({ path, engagementId: e.id, patternCount: e.markers.length });
130
+ }
131
+
132
+ // Remove stale marker files whose stem is no longer in the target set.
133
+ let existing: string[] = [];
134
+ try {
135
+ existing = readdirSync(dir).filter(f => f.endsWith(".txt"));
136
+ } catch {
137
+ /* dir may not exist yet in dry-run */
138
+ }
139
+ const removed: string[] = [];
140
+ for (const fname of existing) {
141
+ const stem = fname.replace(/\.txt$/, "");
142
+ if (!targetStems.has(stem)) {
143
+ const p = join(dir, fname);
144
+ if (!dryRun) {
145
+ try {
146
+ unlinkSync(p);
147
+ } catch {
148
+ /* ignore */
149
+ }
150
+ }
151
+ removed.push(p);
152
+ }
153
+ }
154
+
155
+ // Flat union for back-compat.
156
+ let flatWritten: string | null = null;
157
+ if (!dryRun) {
158
+ const flatBody = buildFlatUnion(reg, retentionMonths);
159
+ writeFileSync(flatPath, flatBody, { mode: 0o600 });
160
+ try {
161
+ chmodSync(flatPath, 0o600);
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ flatWritten = flatPath;
166
+ } else {
167
+ flatWritten = flatPath;
168
+ }
169
+
170
+ // Best-effort: ensure home dir is mode 700 too.
171
+ if (!dryRun) {
172
+ try {
173
+ chmodSync(repoAegisHome(), 0o700);
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ }
178
+
179
+ return { written, removed, flat: flatWritten, invalidPatterns };
180
+ }
181
+
182
+ function writeMarkerFile(path: string, engagementId: string, patterns: string[], name?: string): void {
183
+ const header =
184
+ `; generated by repo-aegis render — do not edit by hand\n` +
185
+ `; repo-aegis-marker-format: ${MARKER_FORMAT_VERSION}\n` +
186
+ `; engagement: ${engagementId}${name ? ` (${name})` : ""}\n`;
187
+ writeFileSync(path, header + patterns.join("\n") + (patterns.length > 0 ? "\n" : ""), {
188
+ mode: 0o600,
189
+ });
190
+ try {
191
+ chmodSync(path, 0o600);
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ }
196
+
197
+ function buildFlatUnion(reg: Registry, retentionMonths: number): string {
198
+ const parts: string[] = [
199
+ "; generated by repo-aegis render — back-compat union of all active engagements",
200
+ `; repo-aegis-marker-format: ${MARKER_FORMAT_VERSION}`,
201
+ "",
202
+ ];
203
+ if (reg.alwaysBlock.length > 0) {
204
+ parts.push(`; ${ALWAYS_FILE_STEM}`);
205
+ parts.push(...reg.alwaysBlock);
206
+ parts.push("");
207
+ }
208
+ for (const e of reg.engagements as Engagement[]) {
209
+ if (!isActive(e, retentionMonths)) continue;
210
+ parts.push(`; ${e.id}${e.name ? ` (${e.name})` : ""}`);
211
+ parts.push(...e.markers);
212
+ parts.push("");
213
+ }
214
+ return parts.join("\n");
215
+ }
@@ -0,0 +1,275 @@
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, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import {
10
+ readRepoConfig,
11
+ addEngagement,
12
+ addEngagements,
13
+ removeEngagement,
14
+ setClass,
15
+ unsetClass,
16
+ REPO_CLASSES,
17
+ } from "./repo.js";
18
+ import { NotAGitRepoError } from "./exceptions.js";
19
+
20
+ // Each describe block owns its own tmp/gitDir/nonGitDir to avoid cross-block
21
+ // state leakage when a test fails mid-cleanup. Previously a single file-level
22
+ // `before` allocated one tmp directory shared across every describe; a
23
+ // failure inside any `it` could leave git config in a state that broke later
24
+ // blocks. Per-describe fresh dirs make each block hermetic.
25
+
26
+ function mkGitDir(parent: string, name: string): string {
27
+ const dir = join(parent, name);
28
+ execFileSync("mkdir", ["-p", dir]);
29
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: dir });
30
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
31
+ execFileSync("git", ["config", "user.name", "test"], { cwd: dir });
32
+ return dir;
33
+ }
34
+
35
+ describe("readRepoConfig", () => {
36
+ let tmp: string;
37
+ let nonGitDir: string;
38
+ let gitDir: string;
39
+
40
+ before(() => {
41
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-repo-rrc-"));
42
+ nonGitDir = join(tmp, "non-git");
43
+ execFileSync("mkdir", ["-p", nonGitDir]);
44
+ gitDir = mkGitDir(tmp, "git");
45
+ });
46
+
47
+ after(() => {
48
+ rmSync(tmp, { recursive: true, force: true });
49
+ });
50
+
51
+ it("returns isGitRepo=false outside a git repo", () => {
52
+ const cfg = readRepoConfig(nonGitDir);
53
+ assert.equal(cfg.isGitRepo, false);
54
+ assert.equal(cfg.class, "private-strict");
55
+ assert.equal(cfg.engagements.length, 0);
56
+ });
57
+
58
+ it("returns isGitRepo=true with default class inside a git repo without config", () => {
59
+ const cfg = readRepoConfig(gitDir);
60
+ assert.equal(cfg.isGitRepo, true);
61
+ assert.equal(cfg.class, "private-strict");
62
+ assert.equal(cfg.classExplicit, false);
63
+ });
64
+
65
+ it("reads class when set", () => {
66
+ setClass("public-eligible", gitDir);
67
+ const cfg = readRepoConfig(gitDir);
68
+ assert.equal(cfg.class, "public-eligible");
69
+ assert.equal(cfg.classExplicit, true);
70
+ unsetClass(gitDir);
71
+ });
72
+
73
+ it("falls back to private-strict when class value is invalid", () => {
74
+ execFileSync("git", ["config", "repo-aegis.class", "bogus"], { cwd: gitDir });
75
+ const cfg = readRepoConfig(gitDir);
76
+ assert.equal(cfg.class, "private-strict");
77
+ unsetClass(gitDir);
78
+ });
79
+ });
80
+
81
+ describe("addEngagement / removeEngagement", () => {
82
+ let tmp: string;
83
+ let nonGitDir: string;
84
+ let gitDir: string;
85
+
86
+ before(() => {
87
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-repo-eng-"));
88
+ nonGitDir = join(tmp, "non-git");
89
+ execFileSync("mkdir", ["-p", nonGitDir]);
90
+ gitDir = mkGitDir(tmp, "git");
91
+ });
92
+
93
+ after(() => {
94
+ rmSync(tmp, { recursive: true, force: true });
95
+ });
96
+
97
+ it("adds and removes an engagement idempotently", () => {
98
+ const r1 = addEngagement("customer-a", gitDir);
99
+ assert.equal(r1, true);
100
+ const r2 = addEngagement("customer-a", gitDir);
101
+ assert.equal(r2, false); // already present
102
+ const cfg = readRepoConfig(gitDir);
103
+ assert.ok(cfg.engagements.includes("customer-a"));
104
+ const removed = removeEngagement("customer-a", gitDir);
105
+ assert.equal(removed, true);
106
+ const cfg2 = readRepoConfig(gitDir);
107
+ assert.ok(!cfg2.engagements.includes("customer-a"));
108
+ });
109
+
110
+ it("removeEngagement returns false when not present", () => {
111
+ const r = removeEngagement("never-was-set", gitDir);
112
+ assert.equal(r, false);
113
+ });
114
+
115
+ it("supports multiple engagements (multi-value config)", () => {
116
+ addEngagement("customer-a", gitDir);
117
+ addEngagement("customer-b", gitDir);
118
+ const cfg = readRepoConfig(gitDir);
119
+ assert.deepEqual([...cfg.engagements].sort(), ["customer-a", "customer-b"]);
120
+ removeEngagement("customer-a", gitDir);
121
+ removeEngagement("customer-b", gitDir);
122
+ });
123
+
124
+ it("addEngagements multi-add returns only newly-added ids", () => {
125
+ addEngagement("customer-a", gitDir);
126
+ const added = addEngagements(["customer-a", "customer-b", "customer-c"], gitDir);
127
+ assert.deepEqual(added.sort(), ["customer-b", "customer-c"]);
128
+ removeEngagement("customer-a", gitDir);
129
+ removeEngagement("customer-b", gitDir);
130
+ removeEngagement("customer-c", gitDir);
131
+ });
132
+
133
+ it("escapes regex-special characters in engagement id when removing", () => {
134
+ // ids shouldn't typically have special chars, but verify the escape works
135
+ addEngagement("customer.a", gitDir);
136
+ addEngagement("customer-a", gitDir);
137
+ removeEngagement("customer.a", gitDir);
138
+ const cfg = readRepoConfig(gitDir);
139
+ // customer-a should still be present; only customer.a removed
140
+ assert.ok(cfg.engagements.includes("customer-a"));
141
+ assert.ok(!cfg.engagements.includes("customer.a"));
142
+ removeEngagement("customer-a", gitDir);
143
+ });
144
+
145
+ it("throws NotAGitRepoError outside a git repo", () => {
146
+ assert.throws(() => addEngagement("x", nonGitDir), NotAGitRepoError);
147
+ assert.throws(() => removeEngagement("x", nonGitDir), NotAGitRepoError);
148
+ });
149
+ });
150
+
151
+ describe("setClass / unsetClass", () => {
152
+ let tmp: string;
153
+ let nonGitDir: string;
154
+ let gitDir: string;
155
+
156
+ before(() => {
157
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-repo-cls-"));
158
+ nonGitDir = join(tmp, "non-git");
159
+ execFileSync("mkdir", ["-p", nonGitDir]);
160
+ gitDir = mkGitDir(tmp, "git");
161
+ });
162
+
163
+ after(() => {
164
+ rmSync(tmp, { recursive: true, force: true });
165
+ });
166
+
167
+ it("each REPO_CLASSES value can round-trip", () => {
168
+ for (const cls of REPO_CLASSES) {
169
+ setClass(cls, gitDir);
170
+ const cfg = readRepoConfig(gitDir);
171
+ assert.equal(cfg.class, cls);
172
+ }
173
+ unsetClass(gitDir);
174
+ });
175
+
176
+ it("unsetClass on already-unset config does not throw", () => {
177
+ unsetClass(gitDir);
178
+ unsetClass(gitDir);
179
+ const cfg = readRepoConfig(gitDir);
180
+ assert.equal(cfg.classExplicit, false);
181
+ });
182
+
183
+ it("setClass throws NotAGitRepoError outside a git repo", () => {
184
+ assert.throws(() => setClass("public-eligible", nonGitDir), NotAGitRepoError);
185
+ });
186
+ });
187
+
188
+ describe(".repo-aegis.yml overrides", () => {
189
+ let tmp: string;
190
+ let overrideRepo: string;
191
+ const yamlPath = (dir: string): string => join(dir, ".repo-aegis.yml");
192
+ const writeOverride = (dir: string, body: string): void => {
193
+ writeFileSync(yamlPath(dir), body);
194
+ };
195
+
196
+ before(() => {
197
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-repo-yml-"));
198
+ overrideRepo = mkGitDir(tmp, "override-repo");
199
+ });
200
+
201
+ after(() => {
202
+ rmSync(tmp, { recursive: true, force: true });
203
+ });
204
+
205
+ it("yml provides class when git config does not", () => {
206
+ writeOverride(overrideRepo, "class: customer-coupled\nengagements: [from-yml]\n");
207
+ const cfg = readRepoConfig(overrideRepo);
208
+ assert.equal(cfg.class, "customer-coupled");
209
+ assert.equal(cfg.classExplicit, true);
210
+ assert.equal(cfg.classFromOverride, true);
211
+ assert.deepEqual(cfg.engagements, ["from-yml"]);
212
+ assert.equal(cfg.engagementsFromOverride, true);
213
+ rmSync(yamlPath(overrideRepo));
214
+ unsetClass(overrideRepo);
215
+ });
216
+
217
+ it("git config wins over yml when both set", () => {
218
+ execFileSync("git", ["config", "repo-aegis.class", "private-strict"], {
219
+ cwd: overrideRepo,
220
+ });
221
+ execFileSync("git", ["config", "--add", "repo-aegis.engagement", "from-config"], {
222
+ cwd: overrideRepo,
223
+ });
224
+ writeOverride(overrideRepo, "class: customer-coupled\nengagements: [from-yml]\n");
225
+ const cfg = readRepoConfig(overrideRepo);
226
+ assert.equal(cfg.class, "private-strict", "git config class wins");
227
+ assert.equal(cfg.classFromOverride, undefined);
228
+ assert.deepEqual(cfg.engagements, ["from-config"]);
229
+ assert.equal(cfg.engagementsFromOverride, undefined);
230
+ execFileSync("git", ["config", "--unset-all", "repo-aegis.class"], { cwd: overrideRepo });
231
+ execFileSync("git", ["config", "--unset-all", "repo-aegis.engagement"], { cwd: overrideRepo });
232
+ rmSync(yamlPath(overrideRepo));
233
+ });
234
+
235
+ it("invalid class in yml throws RepoOverrideError", async () => {
236
+ const { RepoOverrideError } = await import("./repo.js");
237
+ writeOverride(overrideRepo, "class: not-a-real-class\n");
238
+ assert.throws(() => readRepoConfig(overrideRepo), RepoOverrideError);
239
+ rmSync(yamlPath(overrideRepo));
240
+ });
241
+
242
+ it("non-array engagements throws RepoOverrideError", async () => {
243
+ const { RepoOverrideError } = await import("./repo.js");
244
+ writeOverride(overrideRepo, "engagements: customer-a\n");
245
+ assert.throws(() => readRepoConfig(overrideRepo), RepoOverrideError);
246
+ rmSync(yamlPath(overrideRepo));
247
+ });
248
+
249
+ it("malformed YAML throws RepoOverrideError", async () => {
250
+ const { RepoOverrideError } = await import("./repo.js");
251
+ writeOverride(overrideRepo, "class: customer-coupled\n unindented: bad\n");
252
+ assert.throws(() => readRepoConfig(overrideRepo), RepoOverrideError);
253
+ rmSync(yamlPath(overrideRepo));
254
+ });
255
+
256
+ it("yml is found via git toplevel even from a subdirectory", () => {
257
+ const sub = join(overrideRepo, "src", "deep");
258
+ execFileSync("mkdir", ["-p", sub]);
259
+ writeOverride(overrideRepo, "class: scratch\n");
260
+ const cfg = readRepoConfig(sub);
261
+ assert.equal(cfg.class, "scratch");
262
+ assert.equal(cfg.classFromOverride, true);
263
+ rmSync(yamlPath(overrideRepo));
264
+ });
265
+
266
+ it("works in non-git dirs (yml at cwd)", () => {
267
+ const dir = join(tmp, "non-git-with-yml");
268
+ execFileSync("mkdir", ["-p", dir]);
269
+ writeOverride(dir, "class: scratch\n");
270
+ const cfg = readRepoConfig(dir);
271
+ assert.equal(cfg.isGitRepo, false);
272
+ assert.equal(cfg.class, "scratch");
273
+ assert.equal(cfg.classFromOverride, true);
274
+ });
275
+ });