ghagga-forge 3.1.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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/adapters/github/github-app-credential-provider.d.ts +102 -0
  4. package/dist/adapters/github/github-app-credential-provider.d.ts.map +1 -0
  5. package/dist/adapters/github/github-app-credential-provider.js +166 -0
  6. package/dist/adapters/github/github-app-credential-provider.js.map +1 -0
  7. package/dist/adapters/github/github-client-port.d.ts +92 -0
  8. package/dist/adapters/github/github-client-port.d.ts.map +1 -0
  9. package/dist/adapters/github/github-client-port.js +24 -0
  10. package/dist/adapters/github/github-client-port.js.map +1 -0
  11. package/dist/adapters/github/github-forge-adapter.d.ts +105 -0
  12. package/dist/adapters/github/github-forge-adapter.d.ts.map +1 -0
  13. package/dist/adapters/github/github-forge-adapter.js +225 -0
  14. package/dist/adapters/github/github-forge-adapter.js.map +1 -0
  15. package/dist/adapters/github/static-token-provider.d.ts +30 -0
  16. package/dist/adapters/github/static-token-provider.d.ts.map +1 -0
  17. package/dist/adapters/github/static-token-provider.js +35 -0
  18. package/dist/adapters/github/static-token-provider.js.map +1 -0
  19. package/dist/adapters/gitlab/gitlab-client-port.d.ts +82 -0
  20. package/dist/adapters/gitlab/gitlab-client-port.d.ts.map +1 -0
  21. package/dist/adapters/gitlab/gitlab-client-port.js +27 -0
  22. package/dist/adapters/gitlab/gitlab-client-port.js.map +1 -0
  23. package/dist/adapters/gitlab/gitlab-forge-adapter.d.ts +118 -0
  24. package/dist/adapters/gitlab/gitlab-forge-adapter.d.ts.map +1 -0
  25. package/dist/adapters/gitlab/gitlab-forge-adapter.js +238 -0
  26. package/dist/adapters/gitlab/gitlab-forge-adapter.js.map +1 -0
  27. package/dist/comment-id.d.ts +45 -0
  28. package/dist/comment-id.d.ts.map +1 -0
  29. package/dist/comment-id.js +48 -0
  30. package/dist/comment-id.js.map +1 -0
  31. package/dist/errors.d.ts +48 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +67 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/index.d.ts +35 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +34 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/lint-boundary.d.ts +36 -0
  40. package/dist/lint-boundary.d.ts.map +1 -0
  41. package/dist/lint-boundary.impl.d.mts +41 -0
  42. package/dist/lint-boundary.impl.mjs +400 -0
  43. package/dist/lint-boundary.js +35 -0
  44. package/dist/lint-boundary.js.map +1 -0
  45. package/dist/ports/ci-runner.d.ts +48 -0
  46. package/dist/ports/ci-runner.d.ts.map +1 -0
  47. package/dist/ports/ci-runner.js +10 -0
  48. package/dist/ports/ci-runner.js.map +1 -0
  49. package/dist/ports/credential-provider.d.ts +32 -0
  50. package/dist/ports/credential-provider.d.ts.map +1 -0
  51. package/dist/ports/credential-provider.js +10 -0
  52. package/dist/ports/credential-provider.js.map +1 -0
  53. package/dist/ports/forge-adapter.d.ts +174 -0
  54. package/dist/ports/forge-adapter.d.ts.map +1 -0
  55. package/dist/ports/forge-adapter.js +34 -0
  56. package/dist/ports/forge-adapter.js.map +1 -0
  57. package/dist/ports/webhook-codec.d.ts +41 -0
  58. package/dist/ports/webhook-codec.d.ts.map +1 -0
  59. package/dist/ports/webhook-codec.js +18 -0
  60. package/dist/ports/webhook-codec.js.map +1 -0
  61. package/dist/project.d.ts +32 -0
  62. package/dist/project.d.ts.map +1 -0
  63. package/dist/project.js +41 -0
  64. package/dist/project.js.map +1 -0
  65. package/dist/ref.d.ts +20 -0
  66. package/dist/ref.d.ts.map +1 -0
  67. package/dist/ref.js +21 -0
  68. package/dist/ref.js.map +1 -0
  69. package/dist/registry.d.ts +69 -0
  70. package/dist/registry.d.ts.map +1 -0
  71. package/dist/registry.js +68 -0
  72. package/dist/registry.js.map +1 -0
  73. package/dist/types.d.ts +310 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +50 -0
  76. package/dist/types.js.map +1 -0
  77. package/package.json +64 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Pure boundary-checker logic (Node-version-robust home of {@link checkForgeBoundary}).
3
+ *
4
+ * This file is plain JavaScript ON PURPOSE (P0 fix F2 hardening): the lint:boundary
5
+ * runner (`scripts/lint-boundary.mjs`) imports this module DIRECTLY at runtime, so
6
+ * the gate no longer depends on Node's experimental TS type-stripping (which needs
7
+ * Node >= 22.18 / unflagged) and can never crash with ERR_UNKNOWN_FILE_EXTENSION.
8
+ * The TypeScript surface (`lint-boundary.ts`) simply re-exports `checkForgeBoundary`
9
+ * from here and adds the `BoundaryViolation` type, so the unit/real-tree tests keep
10
+ * a fully typed import. See lint-boundary.ts for the full R-AGNOSTIC rule prose.
11
+ *
12
+ * @typedef {{ module: string, reason: string }} BoundaryViolation
13
+ */
14
+
15
+ const CORE_SPECIFIERS = ['ghagga-core', '@ghagga/core'];
16
+ const SERVER_SPECIFIERS = ['ghagga-server', '@ghagga/server'];
17
+ const SERVER_PATH_FRAGMENT = 'apps/server';
18
+
19
+ /**
20
+ * Matches `import ... from '<source>'` AND `export ... from '<source>'`
21
+ * statements. Anchored to start-of-line (`^` + `m` flag) so the keyword in PROSE
22
+ * is never matched. Group 1 = keyword (distinguishes re-exports); group 2 = a
23
+ * top-level `type `; group 3 = clause; group 4 = module specifier.
24
+ */
25
+ const STATIC_RE = /^\s*(import|export)\s+(type\s+)?([\w\s{},*]*?)\s+from\s+['"]([^'"]+)['"]/gm;
26
+
27
+ /**
28
+ * Matches DYNAMIC imports/requires: `import('<source>')` / `require('<source>')`.
29
+ * These always pull a VALUE at runtime, so any such core reference is a violation.
30
+ */
31
+ const DYNAMIC_RE = /(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
32
+
33
+ /** True if every named specifier in the clause is inline-`type` (e.g. `{ type A, type B }`). */
34
+ function allSpecifiersInlineType(clause) {
35
+ const braceMatch = clause.match(/\{([^}]*)\}/);
36
+ if (braceMatch === null) {
37
+ return false;
38
+ }
39
+ const inner = braceMatch[1]?.trim() ?? '';
40
+ if (inner === '') {
41
+ return false;
42
+ }
43
+ const specifiers = inner
44
+ .split(',')
45
+ .map((s) => s.trim())
46
+ .filter((s) => s.length > 0);
47
+ return specifiers.every((s) => s.startsWith('type '));
48
+ }
49
+
50
+ /**
51
+ * Strip block and line comments before scanning. Newlines inside block comments
52
+ * are preserved so line-anchored matching of real code is unaffected.
53
+ */
54
+ function stripComments(source) {
55
+ return source
56
+ .replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '))
57
+ .replace(/\/\/[^\n]*/g, '');
58
+ }
59
+
60
+ /** True if the import specifier targets the server app. */
61
+ function isServerImport(source) {
62
+ if (SERVER_SPECIFIERS.includes(source)) {
63
+ return true;
64
+ }
65
+ return source.includes(SERVER_PATH_FRAGMENT);
66
+ }
67
+
68
+ /**
69
+ * True if the import specifier targets core — the bare package, a SUBPATH
70
+ * (`ghagga-core/graph`), or the scoped alias (`@ghagga/core`, `@ghagga/core/x`).
71
+ */
72
+ function isCoreImport(source) {
73
+ return CORE_SPECIFIERS.some((c) => source === c || source.startsWith(`${c}/`));
74
+ }
75
+
76
+ const SERVER_REASON =
77
+ 'packages/forge MUST NOT import apps/server (R-AGNOSTIC): the server depends on forge, never the reverse.';
78
+ const CORE_VALUE_REASON =
79
+ "forge→core imports are allowed in TYPE position only (R-AGNOSTIC): use `import type { ... } from 'ghagga-core'`.";
80
+
81
+ /**
82
+ * Scan a single source file's text for forge-boundary violations.
83
+ *
84
+ * @param {string} rawSource the file contents to scan.
85
+ * @returns {BoundaryViolation[]} the list of violations (empty when clean).
86
+ */
87
+ export function checkForgeBoundary(rawSource) {
88
+ const violations = [];
89
+ const source = stripComments(rawSource);
90
+
91
+ // ── Static `import ... from` and `export ... from` (incl. re-exports) ──
92
+ STATIC_RE.lastIndex = 0;
93
+ for (let match = STATIC_RE.exec(source); match !== null; match = STATIC_RE.exec(source)) {
94
+ const keyword = match[1]; // 'import' | 'export'
95
+ const isTopLevelTypeImport = match[2] !== undefined; // `import type ...`
96
+ const clause = match[3] ?? '';
97
+ const moduleSource = match[4] ?? '';
98
+
99
+ if (isServerImport(moduleSource)) {
100
+ violations.push({ module: moduleSource, reason: SERVER_REASON });
101
+ continue;
102
+ }
103
+
104
+ if (isCoreImport(moduleSource)) {
105
+ // A re-export republishes the binding as a VALUE escape, EVEN with
106
+ // `export type`. Only a genuine type-only IMPORT is allowed.
107
+ const isReexport = keyword === 'export';
108
+ const typeOnly = !isReexport && (isTopLevelTypeImport || allSpecifiersInlineType(clause));
109
+ if (!typeOnly) {
110
+ violations.push({ module: moduleSource, reason: CORE_VALUE_REASON });
111
+ }
112
+ }
113
+ }
114
+
115
+ // ── Dynamic `import('...')` / `require('...')` — always a VALUE pull ──
116
+ DYNAMIC_RE.lastIndex = 0;
117
+ for (let match = DYNAMIC_RE.exec(source); match !== null; match = DYNAMIC_RE.exec(source)) {
118
+ const moduleSource = match[1] ?? '';
119
+ if (isServerImport(moduleSource)) {
120
+ violations.push({ module: moduleSource, reason: SERVER_REASON });
121
+ } else if (isCoreImport(moduleSource)) {
122
+ violations.push({ module: moduleSource, reason: CORE_VALUE_REASON });
123
+ }
124
+ }
125
+
126
+ return violations;
127
+ }
128
+
129
+ // ───────────────────────────────────────────────────────────────────────────
130
+ // Server → client.ts forge-adapter surface boundary (SDD forge-agnostic 1.6+).
131
+ //
132
+ // WHY THIS EXISTS (P1 4vr Codex contrarian finding): Biome's
133
+ // `noRestrictedImports` only blocks NAMED imports of the 11 @internal
134
+ // forge-adapter fns. A namespace bypass —
135
+ // `import * as gh from '../github/client.js'; gh.fetchPRDiff(...)`
136
+ // — is INVISIBLE to Biome, so the factory's "sole sanctioned consumer"
137
+ // guarantee was theater. review.ts ALREADY namespace-imports client.ts (for the
138
+ // allowed getInstallationToken), so the escape hatch was wide open. This checker
139
+ // closes it for real by ALSO catching namespace-alias + member-access of any of
140
+ // the 11 banned fns. Same dependency-free regex/string-scanning spirit as
141
+ // checkForgeBoundary above. The factory (composition root) and test files are
142
+ // the only sanctioned consumers and are excluded by the RUNNER (by path), not
143
+ // here — this function is pure over (filePath, source).
144
+ // ───────────────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * The 11 @internal forge-adapter functions exported by apps/server's
148
+ * `github/client.ts`. These MUST be consumed only via the GitHubForgeAdapter
149
+ * built through forge-adapter-factory.ts (makeGitHubAdapter). Direct use
150
+ * anywhere else in apps/server is a boundary violation.
151
+ *
152
+ * NOT banned (allowed direct): getInstallationToken, verifyWebhookSignature,
153
+ * plus any constants/types.
154
+ */
155
+ export const BANNED_CLIENT_FORGE_FNS = [
156
+ 'fetchPRDiff',
157
+ 'fetchPRDetails',
158
+ 'getPRFileList',
159
+ 'getPRCommitMessages',
160
+ 'postComment',
161
+ 'findExistingComment',
162
+ 'deleteComment',
163
+ 'updateComment',
164
+ 'addCommentReaction',
165
+ 'fetchGraphFromBranch',
166
+ 'fetchGraphMetadata',
167
+ ];
168
+
169
+ const BANNED_FNS_SET = new Set(BANNED_CLIENT_FORGE_FNS);
170
+
171
+ /**
172
+ * True if an import specifier points at apps/server's `github/client.ts`.
173
+ * Handles every relative form the server uses to reach the SAME module:
174
+ * `./client.js`, `../github/client.js`, `../../github/client.js`, etc. We match
175
+ * on the trailing `client.js` segment of a RELATIVE specifier (leading `.`),
176
+ * which is the canonical compiled-ESM spelling of `github/client.ts`. Bare
177
+ * package specifiers and non-`client` modules never match.
178
+ */
179
+ function isServerForgeClientImport(source) {
180
+ if (!source.startsWith('.')) {
181
+ return false;
182
+ }
183
+ return /(^|\/)client\.(js|ts|mjs|cjs)$/.test(source) || /(^|\/)client$/.test(source);
184
+ }
185
+
186
+ const NAMED_BANNED_REASON =
187
+ 'FORGE BOUNDARY (R-AGNOSTIC 1.6): the 11 client.ts forge-adapter fns are @internal — ' +
188
+ 'consume them via GitHubForgeAdapter built through forge-adapter-factory.ts (makeGitHubAdapter). ' +
189
+ 'Direct named import is forbidden.';
190
+ const NAMESPACE_BANNED_REASON =
191
+ 'FORGE BOUNDARY (R-AGNOSTIC 1.6) — NAMESPACE BYPASS: this file namespace-imports client.ts ' +
192
+ 'and accesses a banned forge-adapter fn via member access (e.g. `alias.fetchPRDiff`). ' +
193
+ "This bypasses Biome's named-import ban. Consume the fn via the GitHubForgeAdapter from " +
194
+ 'forge-adapter-factory.ts (makeGitHubAdapter). getInstallationToken/verifyWebhookSignature stay allowed.';
195
+ const DYNAMIC_BANNED_REASON =
196
+ 'FORGE BOUNDARY (R-AGNOSTIC 1.6) — DYNAMIC-IMPORT BYPASS: this file dynamically ' +
197
+ "loads client.ts (`import('...client.js')` / `require('...client.js')`) and reaches a banned " +
198
+ "forge-adapter fn off the loaded module. This bypasses Biome's STATIC named-import ban entirely. " +
199
+ 'Consume the fn via the GitHubForgeAdapter from forge-adapter-factory.ts (makeGitHubAdapter).';
200
+ const REEXPORT_BANNED_REASON =
201
+ 'FORGE BOUNDARY (R-AGNOSTIC 1.6) — RE-EXPORT LAUNDERING: this file re-exports a banned ' +
202
+ "forge-adapter fn from client.ts (`export { fetchPRDiff } from '...client.js'` or " +
203
+ "`export * from '...client.js'`), republishing an @internal fn as a public binding other " +
204
+ 'modules can import. Consume the fns via the GitHubForgeAdapter from forge-adapter-factory.ts ' +
205
+ '(makeGitHubAdapter); do NOT re-export them.';
206
+ const DESTRUCTURE_BANNED_REASON =
207
+ 'FORGE BOUNDARY (R-AGNOSTIC 1.6) — DESTRUCTURING BYPASS: this file destructures a banned ' +
208
+ 'forge-adapter fn off a binding that aliases client.ts (e.g. `const { fetchPRDiff } = gh` after ' +
209
+ "a namespace/dynamic import). This bypasses Biome's named-import AND member-access checks. " +
210
+ 'Consume the fns via the GitHubForgeAdapter from forge-adapter-factory.ts (makeGitHubAdapter).';
211
+
212
+ /**
213
+ * Matches a dynamic load of a module bound to a NAMESPACE alias:
214
+ * `const gh = await import('...')` / `const gh = require('...')`
215
+ * `let gh = import('...')` / `var gh = require('...')`
216
+ * Group 1 = the alias identifier; group 2 = the module specifier. The bound
217
+ * value is the whole module object, so any later `gh.<bannedFn>` is a bypass —
218
+ * we feed group-1 aliases into the SAME member-access scan as static namespace
219
+ * imports.
220
+ */
221
+ const DYNAMIC_NS_BIND_RE =
222
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
223
+
224
+ /**
225
+ * Matches a DESTRUCTURE off a dynamic load:
226
+ * `const { fetchPRDiff } = await import('...')`
227
+ * `const { fetchPRDiff } = require('...')`
228
+ * Group 1 = the destructure clause `{ ... }`; group 2 = the module specifier.
229
+ */
230
+ const DYNAMIC_DESTRUCTURE_RE =
231
+ /(?:const|let|var)\s*(\{[^}]*\})\s*=\s*(?:await\s+)?(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
232
+
233
+ /** Extract the bare names from a named-import clause `{ a, b as c, type d }`. */
234
+ function namedSpecifiers(clause) {
235
+ const braceMatch = clause.match(/\{([^}]*)\}/);
236
+ if (braceMatch === null) {
237
+ return [];
238
+ }
239
+ return (braceMatch[1] ?? '')
240
+ .split(',')
241
+ .map((s) => s.trim())
242
+ .filter((s) => s.length > 0)
243
+ .map((s) => s.replace(/^type\s+/, '')) // drop inline `type ` modifier
244
+ .map((s) => s.split(/\s+as\s+/)[0].trim()); // imported name (before `as`)
245
+ }
246
+
247
+ /** Escape a string for safe use as a literal inside a dynamically-built RegExp. */
248
+ function escapeRegExp(literal) {
249
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
250
+ }
251
+
252
+ /**
253
+ * Scan a single apps/server source file for direct use of the 11 @internal
254
+ * client.ts forge-adapter fns. Catches ALL of these escape routes:
255
+ *
256
+ * 1. NAMED import: `import { fetchPRDiff } from '../github/client.js'`
257
+ * (defense-in-depth with the Biome `noRestrictedImports` ban)
258
+ * 2. NAMESPACE bypass: `import * as gh from '../github/client.js'`
259
+ * followed by `gh.fetchPRDiff` member access (INVISIBLE to Biome)
260
+ * 3. DYNAMIC-IMPORT bypass: `const gh = await import('...client.js')` /
261
+ * `const gh = require('...client.js')` then `gh.fetchPRDiff` member access
262
+ * (Biome's import linter is STATIC-only, so this sails right past it)
263
+ * 4. RE-EXPORT laundering: `export { fetchPRDiff } from '...client.js'` or
264
+ * `export * from '...client.js'` — republishes the @internal fn as a public
265
+ * binding any module can then legally import
266
+ * 5. DESTRUCTURING bypass: `const { fetchPRDiff } = gh` (off a namespace OR
267
+ * dynamic-import alias) and `const { fetchPRDiff } = await import('...')` /
268
+ * `= require('...')` (destructure straight off the module)
269
+ *
270
+ * Allowed: `getInstallationToken`/`verifyWebhookSignature` (named OR member
271
+ * access OR destructure), constants, and types. The factory and test files are
272
+ * excluded by the RUNNER (by path); this function does not special-case them.
273
+ *
274
+ * @param {string} filePath the file path (for the violation message only).
275
+ * @param {string} rawSource the file contents to scan.
276
+ * @returns {BoundaryViolation[]} the list of violations (empty when clean).
277
+ */
278
+ export function checkServerForgeClientBoundary(filePath, rawSource) {
279
+ const violations = [];
280
+ const source = stripComments(rawSource);
281
+ /**
282
+ * Identifiers bound to the WHOLE client.ts module, tagged by how they were
283
+ * sourced so member/destructure violations carry the right reason:
284
+ * `{ name, dynamic }` — `dynamic:false` = static `import * as`,
285
+ * `dynamic:true` = `await import()` / `require()`.
286
+ */
287
+ const moduleAliases = [];
288
+
289
+ // ── Pass 1: static imports / re-exports of client.ts (named + namespace) ──
290
+ STATIC_RE.lastIndex = 0;
291
+ for (let match = STATIC_RE.exec(source); match !== null; match = STATIC_RE.exec(source)) {
292
+ const keyword = match[1]; // 'import' | 'export'
293
+ const clause = match[3] ?? '';
294
+ const moduleSource = match[4] ?? '';
295
+ if (!isServerForgeClientImport(moduleSource)) {
296
+ continue;
297
+ }
298
+
299
+ const isReexport = keyword === 'export';
300
+
301
+ // `export * from '...client.js'` — wildcard re-export republishes EVERY
302
+ // banned fn as a public binding. The clause has no brace, so the named-spec
303
+ // scan below misses it; flag the wildcard form explicitly.
304
+ if (isReexport && /(^|\s)\*(\s|$)/.test(clause)) {
305
+ violations.push({
306
+ module: `${filePath} → export * from ${moduleSource}`,
307
+ reason: REEXPORT_BANNED_REASON,
308
+ });
309
+ continue;
310
+ }
311
+
312
+ // `import * as alias from '...client.js'` → record alias for member-access
313
+ // + destructure scans (only meaningful for an IMPORT, never an `export *`).
314
+ const nsMatch = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
315
+ if (!isReexport && nsMatch !== null) {
316
+ moduleAliases.push({ name: nsMatch[1], dynamic: false });
317
+ }
318
+
319
+ // Named import OR named re-export of a banned fn. A re-export laundering
320
+ // (`export { fetchPRDiff } from`) gets the dedicated re-export reason; a
321
+ // direct named import gets the named-import reason.
322
+ for (const name of namedSpecifiers(clause)) {
323
+ if (BANNED_FNS_SET.has(name)) {
324
+ violations.push({
325
+ module: `${filePath} → ${moduleSource}`,
326
+ reason: isReexport ? REEXPORT_BANNED_REASON : NAMED_BANNED_REASON,
327
+ });
328
+ }
329
+ }
330
+ }
331
+
332
+ // ── Pass 2: dynamic load bound to an alias → record for member/destructure ──
333
+ DYNAMIC_NS_BIND_RE.lastIndex = 0;
334
+ for (let m = DYNAMIC_NS_BIND_RE.exec(source); m !== null; m = DYNAMIC_NS_BIND_RE.exec(source)) {
335
+ if (isServerForgeClientImport(m[2] ?? '')) {
336
+ moduleAliases.push({ name: m[1], dynamic: true });
337
+ }
338
+ }
339
+
340
+ // ── Pass 3: destructure STRAIGHT off a dynamic load ──
341
+ // `const { fetchPRDiff } = await import('...client.js')` / `= require(...)`
342
+ DYNAMIC_DESTRUCTURE_RE.lastIndex = 0;
343
+ for (
344
+ let m = DYNAMIC_DESTRUCTURE_RE.exec(source);
345
+ m !== null;
346
+ m = DYNAMIC_DESTRUCTURE_RE.exec(source)
347
+ ) {
348
+ if (!isServerForgeClientImport(m[2] ?? '')) {
349
+ continue;
350
+ }
351
+ for (const name of namedSpecifiers(m[1] ?? '')) {
352
+ if (BANNED_FNS_SET.has(name)) {
353
+ violations.push({
354
+ module: `${filePath} → { ${name} } = ${m[2]}`,
355
+ reason: DYNAMIC_BANNED_REASON,
356
+ });
357
+ }
358
+ }
359
+ }
360
+
361
+ // ── Pass 4: per-alias member access AND destructure off the alias ──
362
+ // Aliases come from BOTH static namespace imports (Pass 1) and dynamic loads
363
+ // (Pass 2). Static-namespace aliases that hit a banned member get the
364
+ // NAMESPACE reason; dynamic-load aliases get the DYNAMIC reason. We tag by
365
+ // whether the alias was sourced dynamically.
366
+ for (const { name: alias, dynamic } of moduleAliases) {
367
+ const safeAlias = escapeRegExp(alias);
368
+ const memberReason = dynamic ? DYNAMIC_BANNED_REASON : NAMESPACE_BANNED_REASON;
369
+
370
+ // 4a. member access: `alias.<bannedFn>`
371
+ const memberRe = new RegExp(`\\b${safeAlias}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`, 'g');
372
+ for (let m = memberRe.exec(source); m !== null; m = memberRe.exec(source)) {
373
+ const member = m[1];
374
+ if (BANNED_FNS_SET.has(member)) {
375
+ violations.push({
376
+ module: `${filePath} → ${alias}.${member}`,
377
+ reason: memberReason,
378
+ });
379
+ }
380
+ }
381
+
382
+ // 4b. destructure off the alias: `const { fetchPRDiff } = alias`
383
+ const destructureRe = new RegExp(
384
+ `(?:const|let|var)\\s*(\\{[^}]*\\})\\s*=\\s*${safeAlias}\\b`,
385
+ 'g',
386
+ );
387
+ for (let m = destructureRe.exec(source); m !== null; m = destructureRe.exec(source)) {
388
+ for (const name of namedSpecifiers(m[1] ?? '')) {
389
+ if (BANNED_FNS_SET.has(name)) {
390
+ violations.push({
391
+ module: `${filePath} → { ${name} } = ${alias}`,
392
+ reason: DESTRUCTURE_BANNED_REASON,
393
+ });
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ return violations;
400
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Forge-internal boundary checker (task 0.8).
3
+ *
4
+ * Enforces the R-AGNOSTIC import rules that Biome 2.5 cannot express on its own,
5
+ * specifically the TYPE-vs-VALUE distinction for forge→core imports:
6
+ *
7
+ * - `import type { X } from 'ghagga-core'` ✅ allowed (type position)
8
+ * - `import { type X } from 'ghagga-core'` ✅ allowed (all specifiers inline-type)
9
+ * - `import { X } from 'ghagga-core'` ❌ forbidden (value position)
10
+ * - `export { X } from 'ghagga-core'` ❌ forbidden (re-export = value escape)
11
+ * - `import('ghagga-core')` / `require('ghagga-core')` ❌ forbidden (dynamic = value)
12
+ * - `ghagga-core/<subpath>` / `@ghagga/core` ❌ same rules (subpath & scoped alias)
13
+ * - any import of `apps/server` / `ghagga-server` ❌ forbidden outright
14
+ *
15
+ * Biome's `noRestrictedImports` is a blunt path ban — it would reject the LEGAL
16
+ * `import type` forge→core case (false positive). This checker closes that gap
17
+ * so the boundary test (`lint-boundary.test.ts`) can pin both directions.
18
+ *
19
+ * It is deliberately a small, dependency-free scanner over source text rather
20
+ * than a full AST pass: the boundary rules only care about import-statement
21
+ * forms, which are matched reliably with focused regexes. The Biome overrides in
22
+ * `biome.json` cover the forge↛server and core↛forge path bans; this module
23
+ * adds the type-position nuance for forge→core.
24
+ *
25
+ * IMPLEMENTATION LIVES IN A `.mjs` SIBLING (P0 fix F2 hardening):
26
+ * The actual scanner logic is in `lint-boundary.impl.mjs` (plain JS). The
27
+ * lint:boundary RUNNER (`scripts/lint-boundary.mjs`) imports that `.mjs`
28
+ * DIRECTLY, so the gate never imports a `.ts` file at runtime and therefore does
29
+ * NOT depend on Node's experimental TS type-stripping (Node >= 22.18 / unflagged)
30
+ * — eliminating the `ERR_UNKNOWN_FILE_EXTENSION` Node-version fragility. This
31
+ * module re-exports the function with a precise TS type and owns the
32
+ * {@link BoundaryViolation} interface so the tests keep a fully typed import.
33
+ */
34
+ export { BANNED_CLIENT_FORGE_FNS, checkForgeBoundary, checkServerForgeClientBoundary, } from './lint-boundary.impl.mjs';
35
+ //# sourceMappingURL=lint-boundary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lint-boundary.js","sourceRoot":"","sources":["../src/lint-boundary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAMH,OAAO,EACL,uBAAuB,EACvB,kBAAkB,EAClB,8BAA8B,GAC/B,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The CI runner port — INTERFACE DEFINITION ONLY (task 0.4).
3
+ *
4
+ * This file is intentionally impl-free and unwired. It declares the seam the
5
+ * forge-agnostic layer will use to ensure a CI workflow exists, dispatch a run,
6
+ * and verify an async completion callback. No adapter implements it in P0; no
7
+ * call-site consumes it in P0.
8
+ */
9
+ import type { RepoRef } from '../types.js';
10
+ /** Outcome of {@link CiRunner.ensureWorkflow}. */
11
+ export interface EnsureWorkflowResult {
12
+ /** Whether the required CI workflow is present and ready to dispatch. */
13
+ ready: boolean;
14
+ /** When not ready, a human-readable reason. */
15
+ reason?: string;
16
+ }
17
+ /** A request to dispatch a CI run. */
18
+ export interface CiDispatchRequest {
19
+ /** Repository to run CI in. */
20
+ repo: RepoRef;
21
+ /** Git ref (branch / SHA) to run against. */
22
+ ref: string;
23
+ /** Free-form inputs passed to the workflow. */
24
+ inputs?: Record<string, string>;
25
+ }
26
+ /** Outcome of {@link CiRunner.dispatch}. */
27
+ export interface CiDispatchResult {
28
+ /** Forge-native identifier of the dispatched run. */
29
+ runId: string;
30
+ }
31
+ /**
32
+ * Ensure / dispatch / verify a CI run on a forge.
33
+ *
34
+ * Definition only — implementation and wiring are out of scope for P0.
35
+ */
36
+ export interface CiRunner {
37
+ /** Ensure the required CI workflow exists in the repo. */
38
+ ensureWorkflow(repo: RepoRef): Promise<EnsureWorkflowResult>;
39
+ /** Dispatch a CI run. */
40
+ dispatch(request: CiDispatchRequest): Promise<CiDispatchResult>;
41
+ /**
42
+ * Verify a completion callback's authenticity for a dispatched run.
43
+ *
44
+ * @returns true iff the callback is authentic for `id`.
45
+ */
46
+ verifyCallback(id: string, payload: unknown, signature: string): boolean;
47
+ }
48
+ //# sourceMappingURL=ci-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-runner.d.ts","sourceRoot":"","sources":["../../src/ports/ci-runner.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE3C,kDAAkD;AAClD,MAAM,WAAW,oBAAoB;IACnC,yEAAyE;IACzE,KAAK,EAAE,OAAO,CAAC;IACf,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,sCAAsC;AACtC,MAAM,WAAW,iBAAiB;IAChC,+BAA+B;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,4CAA4C;AAC5C,MAAM,WAAW,gBAAgB;IAC/B,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,QAAQ;IACvB,0DAA0D;IAC1D,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAE7D,yBAAyB;IACzB,QAAQ,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAEhE;;;;OAIG;IACH,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1E"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The CI runner port — INTERFACE DEFINITION ONLY (task 0.4).
3
+ *
4
+ * This file is intentionally impl-free and unwired. It declares the seam the
5
+ * forge-agnostic layer will use to ensure a CI workflow exists, dispatch a run,
6
+ * and verify an async completion callback. No adapter implements it in P0; no
7
+ * call-site consumes it in P0.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=ci-runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ci-runner.js","sourceRoot":"","sources":["../../src/ports/ci-runner.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The forge credential provider port (task 0.5).
3
+ *
4
+ * P1's task 1.3 consumes this seam to obtain a forge access token without the
5
+ * adapter knowing HOW the token is sourced (env var, GitHub App installation
6
+ * token, OAuth refresh, secret manager, …). Keeping this an interface lets the
7
+ * token-acquisition strategy vary per deployment without touching adapters.
8
+ */
9
+ /** Supplies a forge access token on demand. */
10
+ export interface ForgeCredentialProvider {
11
+ /**
12
+ * Resolve a valid forge access token.
13
+ *
14
+ * Implementations are responsible for any caching / refresh. Callers should
15
+ * treat the returned token as short-lived and re-call when they need a fresh
16
+ * one rather than holding it indefinitely.
17
+ */
18
+ getToken(): Promise<string>;
19
+ /**
20
+ * Drop any cached token so the NEXT {@link getToken} re-acquires a fresh one.
21
+ *
22
+ * Callers invoke this on a forge auth failure (HTTP 401/403) — the token was
23
+ * revoked/rotated server-side BEFORE its advertised expiry, so the cache is
24
+ * stale even though it looks valid. This is the in-job recovery seam: catch a
25
+ * {@link ForgeAuthError}, `invalidate()`, re-`getToken()`, retry the call once.
26
+ *
27
+ * Implementations with NO cache (e.g. a fixed-token provider) implement this
28
+ * as a no-op — there is nothing to drop.
29
+ */
30
+ invalidate(): void;
31
+ }
32
+ //# sourceMappingURL=credential-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-provider.d.ts","sourceRoot":"","sources":["../../src/ports/credential-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,+CAA+C;AAC/C,MAAM,WAAW,uBAAuB;IACtC;;;;;;OAMG;IACH,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAE5B;;;;;;;;;;OAUG;IACH,UAAU,IAAI,IAAI,CAAC;CACpB"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The forge credential provider port (task 0.5).
3
+ *
4
+ * P1's task 1.3 consumes this seam to obtain a forge access token without the
5
+ * adapter knowing HOW the token is sourced (env var, GitHub App installation
6
+ * token, OAuth refresh, secret manager, …). Keeping this an interface lets the
7
+ * token-acquisition strategy vary per deployment without touching adapters.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=credential-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-provider.js","sourceRoot":"","sources":["../../src/ports/credential-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}