@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,178 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { parse } from "yaml";
5
+ import { z } from "zod";
6
+ import { registryPath } from "./paths.js";
7
+ import {
8
+ RegistryNotFoundError,
9
+ RegistryParseError,
10
+ RegistryEncryptedError,
11
+ } from "./exceptions.js";
12
+ import { registryFileSchema, formatZodError } from "./schemas.js";
13
+
14
+ export interface Engagement {
15
+ id: string;
16
+ name: string;
17
+ started?: string | null;
18
+ ended?: string | null;
19
+ reposActive?: string[];
20
+ markers: string[];
21
+ notes?: string;
22
+ /**
23
+ * GitHub orgs that map to this engagement (Phase 1, schemaVersion 2+).
24
+ * A repo whose origin's org appears here is auto-classified
25
+ * `customer-coupled` with this engagement. Lowercased, GitHub
26
+ * org-name shape; cross-validated for uniqueness across all
27
+ * engagements and disjointness with {@link Registry.personalOrgs}.
28
+ */
29
+ githubOrgs?: string[];
30
+ }
31
+
32
+ export interface Registry {
33
+ engagements: Engagement[];
34
+ alwaysBlock: string[];
35
+ /**
36
+ * GitHub orgs the user owns / treats as public (schemaVersion 2+).
37
+ * A repo whose origin's org is in this list is auto-classified
38
+ * `public-eligible`. Disjoint from any engagement's `githubOrgs`.
39
+ * Optional in the type so callers constructing Registry literals
40
+ * (tests, fixtures) don't have to specify; `loadRegistry` always
41
+ * populates it (defaults to `[]` for v1 registries that don't
42
+ * declare the field).
43
+ */
44
+ personalOrgs?: string[];
45
+ /**
46
+ * Schema version of the on-disk registry. Defaults to 1 when the YAML has
47
+ * no `schemaVersion:` field (legacy). Readers refuse versions
48
+ * greater than {@link MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION}. Optional in
49
+ * the type so callers constructing Registry literals (tests, fixtures)
50
+ * don't have to specify; loadRegistry always populates it.
51
+ */
52
+ schemaVersion?: number;
53
+ }
54
+
55
+ export const ALWAYS_BLOCK_RESERVED_ID = "_always";
56
+
57
+ /**
58
+ * Highest registry `schemaVersion` this build of repo-aegis can read.
59
+ *
60
+ * - v1 (legacy): no `schemaVersion`, no `personalOrgs`, no
61
+ * `engagements[*].githubOrgs`. Loaded with `personalOrgs: []` and every
62
+ * engagement's `githubOrgs` undefined.
63
+ * - v2 (Phase 1 onboarding): adds `personalOrgs` and
64
+ * `engagements[*].githubOrgs` for org-keyed JIT classification.
65
+ *
66
+ * Reader policy:
67
+ * - missing field => treat as version 1 (legacy);
68
+ * - version <= MAX => accept (unknown sibling fields are ignored);
69
+ * - version > MAX => refuse with "upgrade required".
70
+ * Writers must never lower the version.
71
+ */
72
+ export const MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION = 2;
73
+
74
+ export function loadRegistry(path: string = registryPath()): Registry {
75
+ // If the plaintext registry is absent but a sibling `<path>.age`
76
+ // ciphertext exists, the registry is in its encrypted-at-rest state.
77
+ // We deliberately do NOT auto-decrypt: the whole point of the
78
+ // encryption is that the registry only goes plaintext when the user
79
+ // explicitly opts in with `repo-aegis registry decrypt --identity
80
+ // <path>`. Auto-decrypt would defeat the purpose.
81
+ if (!existsSync(path)) {
82
+ const ciphertextPath = `${path}.age`;
83
+ if (existsSync(ciphertextPath)) {
84
+ throw new RegistryEncryptedError(path, ciphertextPath);
85
+ }
86
+ throw new RegistryNotFoundError(path);
87
+ }
88
+ let parsed: unknown;
89
+ try {
90
+ parsed = parse(readFileSync(path, "utf8"));
91
+ } catch (err) {
92
+ throw new RegistryParseError(path, err);
93
+ }
94
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
95
+ throw new RegistryParseError(path, new Error("registry must be a YAML mapping"));
96
+ }
97
+ // The "missing 'engagements:'" failure used to be a separate check
98
+ // ahead of structural validation. Surfacing it explicitly makes the
99
+ // common-case error message punchier than zod's generic
100
+ // "Required" — and several tests pin on this wording.
101
+ if (!("engagements" in parsed)) {
102
+ throw new RegistryParseError(path, new Error("missing 'engagements:' top-level key"));
103
+ }
104
+
105
+ let validated;
106
+ try {
107
+ validated = registryFileSchema.parse(parsed);
108
+ } catch (err) {
109
+ if (err instanceof z.ZodError) {
110
+ throw new RegistryParseError(path, new Error(formatZodError(err, "registry")));
111
+ }
112
+ throw err;
113
+ }
114
+
115
+ // Schema-version gate (per design B14). Zod has accepted any number;
116
+ // the policy decision (reject newer-than-supported with an upgrade
117
+ // hint) is encoded here rather than as a refine() so the error
118
+ // wording is exactly what the user-facing tests assert on.
119
+ const schemaVersion = validated.schemaVersion ?? 1;
120
+ if (schemaVersion > MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION) {
121
+ throw new RegistryParseError(
122
+ path,
123
+ new Error(
124
+ `registry schemaVersion ${schemaVersion} is newer than this build supports ` +
125
+ `(max ${MAX_SUPPORTED_REGISTRY_SCHEMA_VERSION}); ` +
126
+ `registry written by a newer repo-aegis — please upgrade`,
127
+ ),
128
+ );
129
+ }
130
+
131
+ // Map zod's struct shape back to the domain types. The `passthrough()`
132
+ // on the schema preserves unknown sibling fields, but those are not
133
+ // part of the public Registry interface — drop them at the boundary.
134
+ const engagements: Engagement[] = validated.engagements.map(e => ({
135
+ id: e.id,
136
+ name: e.name,
137
+ ...(e.started !== undefined && { started: e.started }),
138
+ ...(e.ended !== undefined && { ended: e.ended }),
139
+ ...(e.reposActive !== undefined && { reposActive: e.reposActive }),
140
+ markers: e.markers,
141
+ ...(e.notes !== undefined && { notes: e.notes }),
142
+ ...(e.githubOrgs !== undefined && { githubOrgs: e.githubOrgs }),
143
+ }));
144
+
145
+ return {
146
+ engagements,
147
+ alwaysBlock: validated.always_block ?? [],
148
+ personalOrgs: validated.personalOrgs ?? [],
149
+ schemaVersion,
150
+ };
151
+ }
152
+
153
+ export function isActive(e: Engagement, retentionMonths = 12): boolean {
154
+ if (!e.ended) return true;
155
+ const endedDate = new Date(e.ended);
156
+ if (Number.isNaN(endedDate.getTime())) return true;
157
+ const cutoff = new Date();
158
+ cutoff.setMonth(cutoff.getMonth() - retentionMonths);
159
+ return endedDate > cutoff;
160
+ }
161
+
162
+ export interface ResolveResult {
163
+ match: Engagement | null;
164
+ candidates: Engagement[];
165
+ }
166
+
167
+ export function resolveEngagement(reg: Registry, query: string): ResolveResult {
168
+ const q = query.toLowerCase();
169
+ const exactId = reg.engagements.find(e => e.id.toLowerCase() === q);
170
+ if (exactId) return { match: exactId, candidates: [exactId] };
171
+ const exactName = reg.engagements.find(e => e.name.toLowerCase() === q);
172
+ if (exactName) return { match: exactName, candidates: [exactName] };
173
+ const fuzzy = reg.engagements.filter(
174
+ e => e.id.toLowerCase().includes(q) || e.name.toLowerCase().includes(q),
175
+ );
176
+ if (fuzzy.length === 1) return { match: fuzzy[0]!, candidates: fuzzy };
177
+ return { match: null, candidates: fuzzy };
178
+ }
@@ -0,0 +1,121 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { parseRemoteUrl } from "./remote-url.js";
6
+
7
+ describe("parseRemoteUrl — accepted forms", () => {
8
+ const cases: Array<[string, string, string]> = [
9
+ // [input, org, repo]
10
+ ["https://github.com/foo/bar", "foo", "bar"],
11
+ ["https://github.com/foo/bar.git", "foo", "bar"],
12
+ ["https://github.com/foo/bar/", "foo", "bar"],
13
+ ["http://github.com/foo/bar", "foo", "bar"],
14
+ ["http://github.com/foo/bar.git", "foo", "bar"],
15
+ ["git@github.com:foo/bar", "foo", "bar"],
16
+ ["git@github.com:foo/bar.git", "foo", "bar"],
17
+ ["git@github.com-personal:foo/bar.git", "foo", "bar"],
18
+ ["git@github.com-work:foo/bar.git", "foo", "bar"],
19
+ ["git@github.com-MIXED_alias-1:foo/bar", "foo", "bar"],
20
+ ["ssh://git@github.com/foo/bar.git", "foo", "bar"],
21
+ ["ssh://git@github.com/foo/bar", "foo", "bar"],
22
+ ["https://user@github.com/foo/bar.git", "foo", "bar"],
23
+ ["https://user:token@github.com/foo/bar.git", "foo", "bar"],
24
+ // Lowercasing
25
+ ["https://github.com/Foo-Corp/Bar-Repo.git", "foo-corp", "bar-repo"],
26
+ ["git@github.com:DELL/UMP.git", "dell", "ump"],
27
+ // Whitespace trimmed
28
+ [" https://github.com/foo/bar.git \n", "foo", "bar"],
29
+ // Hyphens, digits, dots in repo names
30
+ ["https://github.com/foo/bar.baz.git", "foo", "bar.baz"],
31
+ ["https://github.com/foo/bar_baz", "foo", "bar_baz"],
32
+ ["https://github.com/foo/bar-123", "foo", "bar-123"],
33
+ ];
34
+
35
+ for (const [input, org, repo] of cases) {
36
+ it(`parses ${JSON.stringify(input)}`, () => {
37
+ const parsed = parseRemoteUrl(input);
38
+ assert.deepEqual(parsed, { host: "github.com", org, repo });
39
+ });
40
+ }
41
+ });
42
+
43
+ describe("parseRemoteUrl — rejected forms (return null)", () => {
44
+ const cases: string[] = [
45
+ // Non-string
46
+ "",
47
+ " ",
48
+ // Non-github hosts
49
+ "https://gitlab.com/foo/bar.git",
50
+ "git@gitlab.com:foo/bar.git",
51
+ "https://bitbucket.org/foo/bar.git",
52
+ "https://example.com/foo/bar",
53
+ "git@example.com:foo/bar.git",
54
+ // Self-hosted github-like host
55
+ "https://github.example.com/foo/bar.git",
56
+ "https://my-github.com/foo/bar.git",
57
+ // Missing repo
58
+ "https://github.com/foo",
59
+ "https://github.com/foo/",
60
+ "git@github.com:foo",
61
+ // Missing org
62
+ "https://github.com//bar",
63
+ "git@github.com:/bar.git",
64
+ // Org starting with hyphen
65
+ "https://github.com/-bad/bar",
66
+ "git@github.com:-bad/bar.git",
67
+ // Extra path segments
68
+ "https://github.com/foo/bar/tree/main",
69
+ "https://github.com/foo/bar/issues/1",
70
+ // Garbage
71
+ "not a url",
72
+ "://github.com/foo/bar",
73
+ "github.com/foo/bar",
74
+ // Empty parts
75
+ "https://github.com/",
76
+ "git@github.com:",
77
+ ];
78
+
79
+ for (const input of cases) {
80
+ it(`rejects ${JSON.stringify(input)}`, () => {
81
+ assert.equal(parseRemoteUrl(input), null);
82
+ });
83
+ }
84
+
85
+ it("returns null for non-string input (typed)", () => {
86
+ assert.equal(parseRemoteUrl(undefined), null);
87
+ assert.equal(parseRemoteUrl(null), null);
88
+ assert.equal(parseRemoteUrl(42), null);
89
+ assert.equal(parseRemoteUrl({}), null);
90
+ assert.equal(parseRemoteUrl([]), null);
91
+ });
92
+ });
93
+
94
+ describe("parseRemoteUrl — fuzz", () => {
95
+ it("never throws on random ASCII input", () => {
96
+ const seed = 0xc0ffee;
97
+ let s = seed;
98
+ function rand(): number {
99
+ // xorshift32 — deterministic
100
+ s ^= s << 13;
101
+ s ^= s >>> 17;
102
+ s ^= s << 5;
103
+ return Math.abs(s);
104
+ }
105
+ for (let i = 0; i < 1000; i++) {
106
+ const len = rand() % 64;
107
+ let str = "";
108
+ for (let j = 0; j < len; j++) {
109
+ str += String.fromCharCode(0x20 + (rand() % 95));
110
+ }
111
+ // Should never throw, regardless of input.
112
+ const result = parseRemoteUrl(str);
113
+ // Sanity: result is null or has the expected shape.
114
+ if (result !== null) {
115
+ assert.equal(typeof result.host, "string");
116
+ assert.equal(typeof result.org, "string");
117
+ assert.equal(typeof result.repo, "string");
118
+ }
119
+ }
120
+ });
121
+ });
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Richard Myers and contributors.
3
+ //
4
+ // Pure parser for git remote URLs. Used by `classify` to derive the
5
+ // engagement (or personal-org) attribution from `git remote get-url
6
+ // origin`.
7
+ //
8
+ // The parser is total: malformed input returns `null` rather than
9
+ // throwing. This is load-bearing — `parseRemoteUrl` runs in the JIT
10
+ // classify path, and a thrown exception there would surface to the
11
+ // agent as a tool-failure rather than the intended `skipped` status.
12
+ //
13
+ // Phase 1 scope: github.com only (including multi-account ssh aliases
14
+ // like `git@github.com-personal:`). Non-github hosts return `null`;
15
+ // extending to gitlab/bitbucket is a deliberate follow-up.
16
+
17
+ export interface ParsedRemote {
18
+ /** Always `"github.com"` in v1; ssh-alias suffixes are stripped. */
19
+ host: string;
20
+ /** Lowercased GitHub org name. */
21
+ org: string;
22
+ /** Lowercased GitHub repo name (no `.git` suffix). */
23
+ repo: string;
24
+ }
25
+
26
+ // SSH form: `git@github.com[-<alias>]:<org>/<repo>[.git][/]`
27
+ // The optional `-<alias>` segment is the multi-account ssh pattern
28
+ // recommended by GitHub for users with multiple accounts on one
29
+ // machine — e.g. `git@github.com-personal:foo/bar.git`. The alias
30
+ // is stripped and the host normalised back to `github.com`.
31
+ const SSH_RE =
32
+ /^git@github\.com(?:-[a-zA-Z0-9_-]+)?:([a-zA-Z0-9][a-zA-Z0-9-]*)\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/;
33
+
34
+ // URL forms: `(http|https|ssh)://[user[:pw]@]github.com/<org>/<repo>[.git][/]`
35
+ // Credential prefix (`user@` or `user:pw@`) is stripped. We do not
36
+ // validate password content; the gate path never sees this URL.
37
+ const URL_RE =
38
+ /^(?:https?|ssh):\/\/(?:[^@/]+@)?github\.com\/([a-zA-Z0-9][a-zA-Z0-9-]*)\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/;
39
+
40
+ /**
41
+ * Parse a git remote URL into `{ host, org, repo }`. Returns `null`
42
+ * for malformed input or non-github hosts. Never throws.
43
+ *
44
+ * Org and repo are lowercased in the output. The original casing is
45
+ * not preserved — callers that need the casing-as-typed must read
46
+ * the raw remote themselves.
47
+ */
48
+ export function parseRemoteUrl(raw: unknown): ParsedRemote | null {
49
+ if (typeof raw !== "string") return null;
50
+ const url = raw.trim();
51
+ if (url.length === 0) return null;
52
+
53
+ const sshMatch = SSH_RE.exec(url);
54
+ if (sshMatch) {
55
+ const org = sshMatch[1];
56
+ const repo = sshMatch[2];
57
+ if (org === undefined || repo === undefined) return null;
58
+ return {
59
+ host: "github.com",
60
+ org: org.toLowerCase(),
61
+ repo: repo.toLowerCase(),
62
+ };
63
+ }
64
+
65
+ const urlMatch = URL_RE.exec(url);
66
+ if (urlMatch) {
67
+ const org = urlMatch[1];
68
+ const repo = urlMatch[2];
69
+ if (org === undefined || repo === undefined) return null;
70
+ return {
71
+ host: "github.com",
72
+ org: org.toLowerCase(),
73
+ repo: repo.toLowerCase(),
74
+ };
75
+ }
76
+
77
+ return null;
78
+ }
@@ -0,0 +1,206 @@
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, readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { renderMarkers, MARKER_FORMAT_VERSION } from "./render.js";
9
+ import { computeDenySet } from "./deny-set.js";
10
+ import { PatternValidationError } from "./exceptions.js";
11
+
12
+ let tmp: string;
13
+
14
+ before(() => {
15
+ tmp = mkdtempSync(join(tmpdir(), "repo-aegis-render-"));
16
+ });
17
+
18
+ after(() => {
19
+ rmSync(tmp, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("renderMarkers", () => {
23
+ it("writes _always.txt and per-engagement files", () => {
24
+ const dir = join(tmp, "case-1");
25
+ mkdirSync(dir, { recursive: true });
26
+ const r = renderMarkers(
27
+ {
28
+ engagements: [
29
+ { id: "customer-a", name: "A", markers: ["acme-corp"] },
30
+ { id: "customer-b", name: "B", markers: ["betaco"] },
31
+ ],
32
+ alwaysBlock: ["PROJECT-CODENAME-ALPHA"],
33
+ schemaVersion: 1,
34
+ },
35
+ { markersDir: dir, flatPath: join(dir, "..", "case-1.flat") },
36
+ );
37
+ assert.equal(r.invalidPatterns.length, 0);
38
+ assert.equal(r.written.length, 3); // _always + 2 engagements
39
+ assert.ok(existsSync(join(dir, "_always.txt")));
40
+ assert.ok(existsSync(join(dir, "customer-a.txt")));
41
+ assert.ok(existsSync(join(dir, "customer-b.txt")));
42
+ const aBody = readFileSync(join(dir, "customer-a.txt"), "utf8");
43
+ assert.match(aBody, /acme-corp/);
44
+ assert.match(aBody, /; engagement: customer-a/);
45
+ });
46
+
47
+ it("dry-run writes nothing", () => {
48
+ const dir = join(tmp, "case-dry");
49
+ const r = renderMarkers(
50
+ {
51
+ engagements: [{ id: "customer-a", name: "A", markers: ["acme"] }],
52
+ alwaysBlock: [],
53
+ schemaVersion: 1,
54
+ },
55
+ { markersDir: dir, dryRun: true, flatPath: join(dir, ".flat") },
56
+ );
57
+ assert.ok(!existsSync(dir));
58
+ assert.equal(r.written.length, 2); // _always + 1 engagement listed but not written
59
+ });
60
+
61
+ it("rejects bad patterns by throwing PatternValidationError", () => {
62
+ const dir = join(tmp, "case-bad");
63
+ mkdirSync(dir, { recursive: true });
64
+ assert.throws(
65
+ () =>
66
+ renderMarkers(
67
+ {
68
+ engagements: [{ id: "customer-a", name: "A", markers: ["(unclosed"] }],
69
+ alwaysBlock: [],
70
+ schemaVersion: 1,
71
+ },
72
+ { markersDir: dir, flatPath: join(dir, ".flat") },
73
+ ),
74
+ PatternValidationError,
75
+ );
76
+ // verify no files were written
77
+ assert.ok(!existsSync(join(dir, "customer-a.txt")));
78
+ });
79
+
80
+ it("removes stale per-engagement files no longer in registry", () => {
81
+ const dir = join(tmp, "case-stale");
82
+ mkdirSync(dir, { recursive: true });
83
+ // Pre-existing stale file
84
+ writeFileSync(join(dir, "old-engagement.txt"), "old-marker");
85
+ const r = renderMarkers(
86
+ {
87
+ engagements: [{ id: "customer-a", name: "A", markers: ["acme"] }],
88
+ alwaysBlock: [],
89
+ schemaVersion: 1,
90
+ },
91
+ { markersDir: dir, flatPath: join(dir, ".flat") },
92
+ );
93
+ assert.ok(r.removed.some(p => p.endsWith("old-engagement.txt")));
94
+ assert.ok(!existsSync(join(dir, "old-engagement.txt")));
95
+ });
96
+
97
+ it("excludes engagements past the retention window", () => {
98
+ const dir = join(tmp, "case-retention");
99
+ mkdirSync(dir, { recursive: true });
100
+ const r = renderMarkers(
101
+ {
102
+ engagements: [
103
+ { id: "active", name: "Active", markers: ["foo"] },
104
+ { id: "past-retention", name: "Past", ended: "2020-01-01", markers: ["bar"] },
105
+ ],
106
+ alwaysBlock: [],
107
+ schemaVersion: 1,
108
+ },
109
+ { markersDir: dir, retentionMonths: 12, flatPath: join(dir, ".flat") },
110
+ );
111
+ assert.ok(r.written.some(w => w.engagementId === "active"));
112
+ assert.ok(!r.written.some(w => w.engagementId === "past-retention"));
113
+ });
114
+
115
+ it("writes the flat union file", () => {
116
+ const dir = join(tmp, "case-flat");
117
+ mkdirSync(dir, { recursive: true });
118
+ const flat = join(tmp, "flat.txt");
119
+ renderMarkers(
120
+ {
121
+ engagements: [{ id: "a", name: "A", markers: ["acme"] }],
122
+ alwaysBlock: ["PROJECT-X"],
123
+ schemaVersion: 1,
124
+ },
125
+ { markersDir: dir, flatPath: flat },
126
+ );
127
+ assert.ok(existsSync(flat));
128
+ const body = readFileSync(flat, "utf8");
129
+ assert.match(body, /acme/);
130
+ assert.match(body, /PROJECT-X/);
131
+ });
132
+
133
+ it("writes the repo-aegis-marker-format header line as a comment", () => {
134
+ const dir = join(tmp, "case-format-header");
135
+ mkdirSync(dir, { recursive: true });
136
+ renderMarkers(
137
+ {
138
+ engagements: [{ id: "customer-a", name: "A", markers: ["foo-marker"] }],
139
+ alwaysBlock: ["GLOBAL-MARKER"],
140
+ schemaVersion: 1,
141
+ },
142
+ { markersDir: dir, flatPath: join(tmp, "case-format-header.flat") },
143
+ );
144
+
145
+ const expected = `; repo-aegis-marker-format: ${MARKER_FORMAT_VERSION}`;
146
+
147
+ // Per-engagement file: header line is present and is a `;`-comment.
148
+ const engBody = readFileSync(join(dir, "customer-a.txt"), "utf8");
149
+ assert.ok(
150
+ engBody.includes(expected),
151
+ `expected per-engagement file to contain "${expected}"; got:\n${engBody}`,
152
+ );
153
+ // _always file too.
154
+ const alwaysBody = readFileSync(join(dir, "_always.txt"), "utf8");
155
+ assert.ok(
156
+ alwaysBody.includes(expected),
157
+ `expected _always file to contain "${expected}"; got:\n${alwaysBody}`,
158
+ );
159
+ // Flat union too.
160
+ const flatBody = readFileSync(join(tmp, "case-format-header.flat"), "utf8");
161
+ assert.ok(
162
+ flatBody.includes(expected),
163
+ `expected flat union to contain "${expected}"; got:\n${flatBody}`,
164
+ );
165
+
166
+ // The header must be the SECOND line of the per-engagement file (after
167
+ // the "generated by" comment) so it's discoverable at the top.
168
+ const lines = engBody.split("\n");
169
+ assert.equal(lines[1], expected);
170
+ });
171
+
172
+ it("marker-format header is ignored by the deny-set parser (round-trip)", () => {
173
+ // Sanity check: the new header must not pollute the pattern set,
174
+ // because deny-set treats any line starting with `;` as a comment.
175
+ const dir = join(tmp, "case-format-roundtrip");
176
+ mkdirSync(dir, { recursive: true });
177
+ renderMarkers(
178
+ {
179
+ engagements: [{ id: "customer-a", name: "A", markers: ["foo-marker"] }],
180
+ alwaysBlock: [],
181
+ schemaVersion: 1,
182
+ },
183
+ { markersDir: dir, flatPath: join(tmp, "case-format-roundtrip.flat") },
184
+ );
185
+ const ds = computeDenySet(
186
+ {
187
+ cwd: dir,
188
+ isGitRepo: false,
189
+ class: "public-eligible",
190
+ classExplicit: true,
191
+ engagements: [],
192
+ },
193
+ { markersDir: dir },
194
+ );
195
+ assert.deepEqual(ds.patterns, ["foo-marker"]);
196
+ // No pattern in the deny set should be a comment line.
197
+ for (const p of ds.patterns) {
198
+ assert.ok(!p.startsWith(";"), `pattern leaked from header: ${p}`);
199
+ assert.ok(!p.includes("repo-aegis-marker-format"), `format header leaked into patterns: ${p}`);
200
+ }
201
+ });
202
+
203
+ it("exports MARKER_FORMAT_VERSION = 1", () => {
204
+ assert.equal(MARKER_FORMAT_VERSION, 1);
205
+ });
206
+ });