@biffo/cli 0.278.2 → 0.278.3

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.
@@ -0,0 +1,127 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const getCurrentUser = vi.fn()
4
+ const poolConstructor = vi.fn()
5
+
6
+ vi.mock('amazon-cognito-identity-js', () => ({
7
+ CognitoUserPool: class {
8
+ constructor(...args: unknown[]) {
9
+ poolConstructor(...args)
10
+ }
11
+ getCurrentUser = getCurrentUser
12
+ },
13
+ }))
14
+
15
+ const resolveCoreIdentity = vi.fn()
16
+ vi.mock('./identity', () => ({ resolveCoreIdentity }))
17
+
18
+ vi.mock('./cognito-hygiene', () => ({ pruneForeignCognitoCredentials: vi.fn() }))
19
+
20
+ // Fresh module each test: the pool is memoised at module scope.
21
+ async function loadAuth() {
22
+ vi.resetModules()
23
+ return await import('./auth')
24
+ }
25
+
26
+ describe('getCurrentSession', () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks()
29
+ resolveCoreIdentity.mockResolvedValue({
30
+ userPoolId: 'us-east-1_DOCPOOL',
31
+ clientId: 'docclientid',
32
+ })
33
+ })
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks()
37
+ })
38
+
39
+ it('returns null when no user is stored in the shared localStorage session', async () => {
40
+ const { getCurrentSession } = await loadAuth()
41
+ getCurrentUser.mockReturnValue(null)
42
+ await expect(getCurrentSession()).resolves.toBeNull()
43
+ })
44
+
45
+ it('returns null when no identity is resolvable', async () => {
46
+ resolveCoreIdentity.mockResolvedValue(null)
47
+ const { getCurrentSession } = await loadAuth()
48
+ await expect(getCurrentSession()).resolves.toBeNull()
49
+ expect(poolConstructor).not.toHaveBeenCalled()
50
+ })
51
+
52
+ it('builds the pool once across multiple sequential session reads (memoised)', async () => {
53
+ const { getCurrentSession } = await loadAuth()
54
+ getCurrentUser.mockReturnValue(null)
55
+
56
+ await getCurrentSession()
57
+ await getCurrentSession()
58
+
59
+ expect(poolConstructor).toHaveBeenCalledTimes(1)
60
+ expect(poolConstructor).toHaveBeenCalledWith({
61
+ UserPoolId: 'us-east-1_DOCPOOL',
62
+ ClientId: 'docclientid',
63
+ })
64
+ })
65
+ })
66
+
67
+ // biffo-plugin-marketing#55: the pool used to be memoised on the SETTLED value,
68
+ // so concurrent callers that started before the first resolution completed each
69
+ // ran an independent resolution (including pruneForeignCognitoCredentials()'s
70
+ // localStorage scan-and-delete). campaign-studio's Pipeline.tsx/Results.tsx now
71
+ // fire several concurrent getArtefact calls on one mount, which is what made
72
+ // this newly load-bearing rather than academic.
73
+ describe('concurrent callers share one pool resolution (biffo-plugin-marketing#55)', () => {
74
+ beforeEach(() => {
75
+ vi.clearAllMocks()
76
+ })
77
+
78
+ afterEach(() => {
79
+ vi.restoreAllMocks()
80
+ })
81
+
82
+ it('resolves the pool exactly once when two callers race before it settles', async () => {
83
+ // A manually-resolved identity lookup guarantees BOTH calls are in flight
84
+ // before resolution, rather than relying on incidental microtask timing.
85
+ let resolveIdentity!: (value: { userPoolId: string; clientId: string }) => void
86
+ resolveCoreIdentity.mockReturnValue(
87
+ new Promise((resolve) => {
88
+ resolveIdentity = resolve
89
+ }),
90
+ )
91
+ const { getCurrentSession } = await loadAuth()
92
+ getCurrentUser.mockReturnValue(null)
93
+
94
+ const first = getCurrentSession()
95
+ const second = getCurrentSession()
96
+
97
+ resolveIdentity({ userPoolId: 'us-east-1_DOCPOOL', clientId: 'docclientid' })
98
+
99
+ await Promise.all([first, second])
100
+
101
+ // The regression this guards: without in-flight memoisation, both callers
102
+ // independently reach the pool constructor before either could observe
103
+ // the other's result.
104
+ expect(poolConstructor).toHaveBeenCalledTimes(1)
105
+ })
106
+
107
+ it('does not cache a rejection — a later call gets a fresh attempt', async () => {
108
+ resolveCoreIdentity.mockResolvedValue({
109
+ userPoolId: 'us-east-1_DOCPOOL',
110
+ clientId: 'docclientid',
111
+ })
112
+ // Simulate a transient failure building the pool itself (identity
113
+ // resolution here never rejects — it catches internally — but the pool
114
+ // construction step must still not be able to poison the memo forever).
115
+ poolConstructor.mockImplementationOnce(() => {
116
+ throw new Error('transient pool construction failure')
117
+ })
118
+ const { getCurrentSession } = await loadAuth()
119
+ getCurrentUser.mockReturnValue(null)
120
+
121
+ await expect(getCurrentSession()).rejects.toThrow('transient pool construction failure')
122
+
123
+ // Retried, not replayed: the second call gets its own attempt and succeeds.
124
+ await expect(getCurrentSession()).resolves.toBeNull()
125
+ expect(poolConstructor).toHaveBeenCalledTimes(2)
126
+ })
127
+ })
@@ -20,23 +20,34 @@ import { resolveCoreIdentity } from './identity'
20
20
  // The pool is built lazily and memoised: a missing identity resolves to null →
21
21
  // "signed out", never a hard crash.
22
22
 
23
- let userPool: CognitoUserPool | null = null
23
+ let poolPromise: Promise<CognitoUserPool | null> | null = null
24
24
 
25
- async function getUserPool(): Promise<CognitoUserPool | null> {
26
- if (userPool) return userPool
27
- const identity = await resolveCoreIdentity()
28
- if (!identity) return null
29
- // Once per page load, and only with a resolved client id: drop credentials
30
- // left behind by pools this deployment no longer uses (biffo-template#834).
31
- // The portal and the sibling skeleton do the same; this origin is shared, so
32
- // whichever app loads first does the cleaning.
33
- pruneForeignCognitoCredentials(identity.clientId)
34
- const poolData: ICognitoUserPoolData = {
35
- UserPoolId: identity.userPoolId,
36
- ClientId: identity.clientId,
37
- }
38
- userPool = new CognitoUserPool(poolData)
39
- return userPool
25
+ // Memoises the IN-FLIGHT promise, not the settled value (biffo-plugin-marketing#55).
26
+ // `poolPromise ??=` assigns synchronously, before the first `await` inside the IIFE
27
+ // runs, so every caller that arrives before the first resolution completes shares
28
+ // that SAME promise instead of each re-running resolveCoreIdentity() and
29
+ // pruneForeignCognitoCredentials()'s localStorage scan-and-delete independently.
30
+ // A rejection clears the memo (`.catch` below) so one transient failure does not
31
+ // poison every later call for the rest of the page's life.
32
+ function getUserPool(): Promise<CognitoUserPool | null> {
33
+ poolPromise ??= (async () => {
34
+ const identity = await resolveCoreIdentity()
35
+ if (!identity) return null
36
+ // Once per page load, and only with a resolved client id: drop credentials
37
+ // left behind by pools this deployment no longer uses (biffo-template#834).
38
+ // The portal and the sibling skeleton do the same; this origin is shared, so
39
+ // whichever app loads first does the cleaning.
40
+ pruneForeignCognitoCredentials(identity.clientId)
41
+ const poolData: ICognitoUserPoolData = {
42
+ UserPoolId: identity.userPoolId,
43
+ ClientId: identity.clientId,
44
+ }
45
+ return new CognitoUserPool(poolData)
46
+ })().catch((err: unknown) => {
47
+ poolPromise = null
48
+ throw err
49
+ })
50
+ return poolPromise
40
51
  }
41
52
 
42
53
  /** The shared portal session, or null if there isn't a valid one (→ redirect to login). */
@@ -78,5 +89,5 @@ export async function getFreshIdToken(): Promise<string | null> {
78
89
 
79
90
  /** Test-only: reset the memoised pool. */
80
91
  export function __resetUserPoolForTests(): void {
81
- userPool = null
92
+ poolPromise = null
82
93
  }
@@ -181,6 +181,68 @@ describe('runtime core identity resolution', () => {
181
181
  })
182
182
  })
183
183
 
184
+ // biffo-plugin-marketing#55: the pool used to be memoised on the SETTLED value,
185
+ // so callers that started before the first resolution completed each ran an
186
+ // independent resolution. Pipeline.tsx firing several concurrent getArtefact
187
+ // calls on mount is what made this newly load-bearing rather than academic.
188
+ describe('concurrent callers share one pool resolution (biffo-plugin-marketing#55)', () => {
189
+ beforeEach(() => {
190
+ vi.clearAllMocks()
191
+ })
192
+
193
+ afterEach(() => {
194
+ vi.unstubAllEnvs()
195
+ vi.restoreAllMocks()
196
+ })
197
+
198
+ it('resolves the pool exactly once when two callers race before it settles', async () => {
199
+ // A manually-resolved fetch guarantees BOTH calls are in flight before
200
+ // resolution, rather than relying on incidental microtask timing.
201
+ let resolveFetch!: (value: unknown) => void
202
+ global.fetch = vi.fn(
203
+ () =>
204
+ new Promise((resolve) => {
205
+ resolveFetch = resolve
206
+ }),
207
+ ) as unknown as typeof fetch
208
+ const { getCurrentSession } = await loadAuth()
209
+ getCurrentUser.mockReturnValue(null)
210
+
211
+ const first = getCurrentSession()
212
+ const second = getCurrentSession()
213
+
214
+ resolveFetch({
215
+ ok: true,
216
+ json: async () => ({ userPoolId: 'us-east-1_DOCPOOL', clientId: 'docclientid' }),
217
+ })
218
+
219
+ await Promise.all([first, second])
220
+
221
+ // The regression this guards: without in-flight memoisation, both callers
222
+ // independently reach the pool constructor (and the localStorage prune
223
+ // inside getUserPool) before either could observe the other's result.
224
+ expect(poolConstructor).toHaveBeenCalledTimes(1)
225
+ })
226
+
227
+ it('does not cache a rejection — a later call gets a fresh attempt', async () => {
228
+ mockFetchDocument()
229
+ // Simulate a transient failure building the pool itself (identity
230
+ // resolution here never rejects — it catches internally — but the pool
231
+ // construction step must still not be able to poison the memo forever).
232
+ poolConstructor.mockImplementationOnce(() => {
233
+ throw new Error('transient pool construction failure')
234
+ })
235
+ const { getCurrentSession } = await loadAuth()
236
+ getCurrentUser.mockReturnValue(null)
237
+
238
+ await expect(getCurrentSession()).rejects.toThrow('transient pool construction failure')
239
+
240
+ // Retried, not replayed: the second call gets its own attempt and succeeds.
241
+ await expect(getCurrentSession()).resolves.toBeNull()
242
+ expect(poolConstructor).toHaveBeenCalledTimes(2)
243
+ })
244
+ })
245
+
184
246
  // The CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
185
247
  // required") when either value is missing, and `next build` prerenders `/` in
186
248
  // Node — which imports this module. Building the pool at module scope therefore
@@ -53,26 +53,37 @@ import { resolveCoreIdentity } from './identity'
53
53
  // The pool itself is memoised: resolveCoreIdentity() is memoised too, but the
54
54
  // CognitoUserPool wrapper is built here exactly once so repeated session reads
55
55
  // reuse one instance (and its localStorage view of the shared session).
56
- let userPool: CognitoUserPool | null = null
57
-
58
- async function getUserPool(): Promise<CognitoUserPool | null> {
59
- if (userPool) return userPool
56
+ //
57
+ // Memoises the IN-FLIGHT promise, not the settled value (biffo-plugin-marketing#55).
58
+ // `poolPromise ??=` assigns synchronously, before the first `await` inside the IIFE
59
+ // runs, so every caller that arrives before the first resolution completes shares
60
+ // that SAME promise instead of each re-running resolveCoreIdentity() and
61
+ // pruneForeignCognitoCredentials()'s localStorage scan-and-delete independently.
62
+ // A rejection clears the memo (`.catch` below) so one transient failure does not
63
+ // poison every later call for the rest of the page's life.
64
+ let poolPromise: Promise<CognitoUserPool | null> | null = null
60
65
 
61
- const identity = await resolveCoreIdentity()
62
- if (!identity) return null
66
+ function getUserPool(): Promise<CognitoUserPool | null> {
67
+ poolPromise ??= (async () => {
68
+ const identity = await resolveCoreIdentity()
69
+ if (!identity) return null
63
70
 
64
- // Once per page load, and only with a resolved client id: drop credentials
65
- // left behind by pools this deployment no longer uses (biffo-template#834).
66
- // Cheap, and it keeps the shared origin from accumulating dead tokens for
67
- // every pool the portal has ever pointed at.
68
- pruneForeignCognitoCredentials(identity.clientId)
71
+ // Once per page load, and only with a resolved client id: drop credentials
72
+ // left behind by pools this deployment no longer uses (biffo-template#834).
73
+ // Cheap, and it keeps the shared origin from accumulating dead tokens for
74
+ // every pool the portal has ever pointed at.
75
+ pruneForeignCognitoCredentials(identity.clientId)
69
76
 
70
- const poolData: ICognitoUserPoolData = {
71
- UserPoolId: identity.userPoolId,
72
- ClientId: identity.clientId,
73
- }
74
- userPool = new CognitoUserPool(poolData)
75
- return userPool
77
+ const poolData: ICognitoUserPoolData = {
78
+ UserPoolId: identity.userPoolId,
79
+ ClientId: identity.clientId,
80
+ }
81
+ return new CognitoUserPool(poolData)
82
+ })().catch((err: unknown) => {
83
+ poolPromise = null
84
+ throw err
85
+ })
86
+ return poolPromise
76
87
  }
77
88
 
78
89
  /**
package/dist/index.js CHANGED
@@ -951,7 +951,7 @@ var GitAdapter = class {
951
951
  * scaffold's throwaway temp dir inherits.
952
952
  */
953
953
  async configuredIdentity(cwd) {
954
- const read = async (key) => {
954
+ const read2 = async (key) => {
955
955
  try {
956
956
  const { stdout } = await execa2("git", ["config", "--get", key], cwd ? { cwd } : {});
957
957
  return stdout.trim() || null;
@@ -959,7 +959,7 @@ var GitAdapter = class {
959
959
  return null;
960
960
  }
961
961
  };
962
- return { name: await read("user.name"), email: await read("user.email") };
962
+ return { name: await read2("user.name"), email: await read2("user.email") };
963
963
  }
964
964
  async isGitRepo(cwd) {
965
965
  try {
@@ -1596,7 +1596,7 @@ var GitHubAdapter = class {
1596
1596
  } catch (err) {
1597
1597
  if (err.status !== 404) throw err;
1598
1598
  }
1599
- await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
1599
+ await new Promise((resolve20) => setTimeout(resolve20, intervalMs));
1600
1600
  }
1601
1601
  throw new Error(
1602
1602
  `Branch "${branch}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
@@ -1611,7 +1611,7 @@ var GitHubAdapter = class {
1611
1611
  } catch (err) {
1612
1612
  if (err.status !== 404) throw err;
1613
1613
  }
1614
- await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
1614
+ await new Promise((resolve20) => setTimeout(resolve20, intervalMs));
1615
1615
  }
1616
1616
  throw new Error(
1617
1617
  `Ref "${ref}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
@@ -1845,7 +1845,7 @@ var GitHubAdapter = class {
1845
1845
  }
1846
1846
  if (status !== 404 || Date.now() >= deadline) throw err;
1847
1847
  log.info("Branch protection endpoint not yet ready, retrying...");
1848
- await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
1848
+ await new Promise((resolve20) => setTimeout(resolve20, protectionIntervalMs));
1849
1849
  }
1850
1850
  }
1851
1851
  }
@@ -2018,7 +2018,7 @@ var GitHubAdapter = class {
2018
2018
  }
2019
2019
  if (status !== 404 || Date.now() >= deadline) throw err;
2020
2020
  log.info("Branch protection endpoint not yet ready, retrying...");
2021
- await new Promise((resolve19) => setTimeout(resolve19, protectionIntervalMs));
2021
+ await new Promise((resolve20) => setTimeout(resolve20, protectionIntervalMs));
2022
2022
  }
2023
2023
  }
2024
2024
  } catch (err) {
@@ -2291,7 +2291,7 @@ var GitHubAdapter = class {
2291
2291
  } catch (err) {
2292
2292
  if (err.status !== 404 || Date.now() >= deadline) throw err;
2293
2293
  log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
2294
- await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
2294
+ await new Promise((resolve20) => setTimeout(resolve20, intervalMs));
2295
2295
  }
2296
2296
  }
2297
2297
  }
@@ -2310,7 +2310,7 @@ var GitHubAdapter = class {
2310
2310
  } catch (err) {
2311
2311
  if (err.status !== 404 || Date.now() >= deadline) throw err;
2312
2312
  log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
2313
- await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
2313
+ await new Promise((resolve20) => setTimeout(resolve20, intervalMs));
2314
2314
  }
2315
2315
  }
2316
2316
  }
@@ -2334,7 +2334,7 @@ var GitHubAdapter = class {
2334
2334
  } else {
2335
2335
  log.info(" Waiting for run to be queued...");
2336
2336
  }
2337
- await new Promise((resolve19) => setTimeout(resolve19, intervalMs));
2337
+ await new Promise((resolve20) => setTimeout(resolve20, intervalMs));
2338
2338
  }
2339
2339
  throw new Error(
2340
2340
  `Workflow ${workflowId} did not complete within ${timeoutMs / 1e3 / 60} minutes`
@@ -4272,7 +4272,7 @@ var AwsAdapter = class {
4272
4272
  const code = err.Code;
4273
4273
  if (code === "OperationAborted" && attempt < maxAttempts) {
4274
4274
  log.info(` Waiting for S3 to release "${bucketName}"... (${attempt}/${maxAttempts})`);
4275
- await new Promise((resolve19) => setTimeout(resolve19, retryDelayMs));
4275
+ await new Promise((resolve20) => setTimeout(resolve20, retryDelayMs));
4276
4276
  } else if (code === "OperationAborted") {
4277
4277
  return false;
4278
4278
  } else {
@@ -10718,8 +10718,8 @@ async function runOwnershipCheck(argv) {
10718
10718
  const { stdout } = await execa11("git", ["diff", "--cached", "--name-status"], { cwd: root });
10719
10719
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
10720
10720
  if (messageFile) {
10721
- const { readFileSync: readFileSync38, existsSync: existsSync46 } = await import("fs");
10722
- if (existsSync46(messageFile)) commitMessage = readFileSync38(messageFile, "utf8");
10721
+ const { readFileSync: readFileSync39, existsSync: existsSync46 } = await import("fs");
10722
+ if (existsSync46(messageFile)) commitMessage = readFileSync39(messageFile, "utf8");
10723
10723
  }
10724
10724
  } else {
10725
10725
  const base = process.env["GITHUB_BASE_REF"] ?? args[0];
@@ -11336,14 +11336,150 @@ async function runPipeTrapCheck() {
11336
11336
  console.log(`\u2713 Pipe-trap guard: no status-bearing command is piped away`);
11337
11337
  }
11338
11338
 
11339
+ // src/scripts/check-plugin-allowlist-convention.ts
11340
+ import { execa as execa15 } from "execa";
11341
+
11342
+ // src/lib/plugin-allowlist-convention.ts
11343
+ import { readFileSync as readFileSync34 } from "fs";
11344
+ import { join as join46 } from "path";
11345
+ var COMPUTE_MAIN_TF = "modules/cloud/aws/compute/main.tf";
11346
+ var PLUGIN_TEMPLATE_MAIN_TF = "modules/plugins/_template/main.tf";
11347
+ var ALLOWLIST_MAIN_TF = "modules/cloud/aws/plugin-allowlist/main.tf";
11348
+ var ALLOWLIST_VARIABLES_TF = "modules/cloud/aws/plugin-allowlist/variables.tf";
11349
+ var PROJECT = "<project>";
11350
+ var ENV = "<env>";
11351
+ var PLUGIN = "<plugin>";
11352
+ var ACCOUNT = "<account>";
11353
+ function read(repoRoot, relative9) {
11354
+ try {
11355
+ return readFileSync34(join46(repoRoot, relative9), "utf8");
11356
+ } catch {
11357
+ throw new Error(`plugin-allowlist drift guard: cannot read ${relative9}`);
11358
+ }
11359
+ }
11360
+ function assignedString(source, name) {
11361
+ const match = new RegExp(`^\\s*${name}\\s*=\\s*"((?:[^"\\\\]|\\\\.)*)"\\s*$`, "m").exec(source);
11362
+ return match?.[1];
11363
+ }
11364
+ function resolve18(expression, bindings) {
11365
+ let current = expression;
11366
+ for (let pass = 0; pass < 10; pass += 1) {
11367
+ const next = current.replace(/\$\{([^}]+)\}/g, (whole, ref) => {
11368
+ const bound = bindings[ref.trim()];
11369
+ return bound === void 0 ? whole : bound;
11370
+ });
11371
+ if (next === current) break;
11372
+ current = next;
11373
+ }
11374
+ return current;
11375
+ }
11376
+ function composeExpectedRoleName(repoRoot) {
11377
+ const compute = read(repoRoot, COMPUTE_MAIN_TF);
11378
+ const template = read(repoRoot, PLUGIN_TEMPLATE_MAIN_TF);
11379
+ const namePrefix = assignedString(compute, "name_prefix");
11380
+ const functionName = assignedString(compute, "function_name");
11381
+ const roleName = /resource\s+"aws_iam_role"\s+"lambda"\s*\{[^}]*?\bname\s*=\s*"([^"]*)"/s.exec(
11382
+ compute
11383
+ )?.[1];
11384
+ if (!namePrefix || !functionName || !roleName) {
11385
+ throw new Error(
11386
+ `plugin-allowlist drift guard: could not read the naming convention out of ${COMPUTE_MAIN_TF}. If that module was restructured, update this guard along with it.`
11387
+ );
11388
+ }
11389
+ const pluginFunctionName = /module\s+"function"\s*\{[\s\S]*?\bfunction_name\s*=\s*"([^"]*)"/.exec(
11390
+ template
11391
+ )?.[1];
11392
+ if (!pluginFunctionName) {
11393
+ throw new Error(
11394
+ `plugin-allowlist drift guard: could not read module "function"'s function_name out of ${PLUGIN_TEMPLATE_MAIN_TF}.`
11395
+ );
11396
+ }
11397
+ const bindings = {
11398
+ "var.project_name": PROJECT,
11399
+ "var.environment": ENV,
11400
+ "var.plugin_name": PLUGIN,
11401
+ "var.function_name": resolve18(pluginFunctionName, {
11402
+ "var.plugin_name": PLUGIN
11403
+ })
11404
+ };
11405
+ bindings["local.name_prefix"] = resolve18(namePrefix, bindings);
11406
+ bindings["local.function_name"] = resolve18(functionName, bindings);
11407
+ return resolve18(roleName, bindings);
11408
+ }
11409
+ function readAllowlistGlob(repoRoot) {
11410
+ const allowlist = read(repoRoot, ALLOWLIST_MAIN_TF);
11411
+ const glob = /for\s+name\s+in\s+var\.enabled_plugins\s*:\s*\n\s*"([^"]*)"/.exec(allowlist)?.[1];
11412
+ if (!glob) {
11413
+ throw new Error(
11414
+ `plugin-allowlist drift guard: could not find the "for name in var.enabled_plugins" glob in ${ALLOWLIST_MAIN_TF}.`
11415
+ );
11416
+ }
11417
+ return resolve18(glob, {
11418
+ "data.aws_caller_identity.current.account_id": ACCOUNT,
11419
+ "var.project_name": PROJECT,
11420
+ "var.environment": ENV,
11421
+ name: PLUGIN
11422
+ });
11423
+ }
11424
+ function checkAllowlistConvention(repoRoot) {
11425
+ const violations = [];
11426
+ const expectedRoleName = composeExpectedRoleName(repoRoot);
11427
+ const expectedGlob = `arn:aws:sts::${ACCOUNT}:assumed-role/${expectedRoleName}/*`;
11428
+ const actualGlob = readAllowlistGlob(repoRoot);
11429
+ if (actualGlob !== expectedGlob) {
11430
+ violations.push({
11431
+ file: ALLOWLIST_MAIN_TF,
11432
+ message: `the allowlist glob no longer matches the role name the naming modules build.
11433
+ ${COMPUTE_MAIN_TF} + ${PLUGIN_TEMPLATE_MAIN_TF} produce: ${expectedGlob}
11434
+ ${ALLOWLIST_MAIN_TF} allowlists: ${actualGlob}
11435
+ Plugins would be rejected by require_service_principal (ADR-0009). Fix the glob, not this guard.`
11436
+ });
11437
+ }
11438
+ const variables = read(repoRoot, ALLOWLIST_VARIABLES_TF);
11439
+ const enabledPluginsBlock = /variable\s+"enabled_plugins"\s*\{[\s\S]*?\n\}/.exec(variables)?.[0];
11440
+ if (!enabledPluginsBlock || !/\bdefault\s*=\s*\[\s*\]/.test(enabledPluginsBlock)) {
11441
+ violations.push({
11442
+ file: ALLOWLIST_VARIABLES_TF,
11443
+ message: "enabled_plugins must default to [] so an unconfigured instance allowlists nobody (ADR-0009 fail-closed)."
11444
+ });
11445
+ }
11446
+ return violations;
11447
+ }
11448
+
11449
+ // src/scripts/check-plugin-allowlist-convention.ts
11450
+ async function runPluginAllowlistConventionCheck() {
11451
+ const root = (await execa15("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11452
+ let violations;
11453
+ try {
11454
+ violations = checkAllowlistConvention(root);
11455
+ } catch (err) {
11456
+ console.error("\u2717 Plugin-allowlist convention guard: could not run\n");
11457
+ console.error(err instanceof Error ? err.message : String(err));
11458
+ process.exit(1);
11459
+ }
11460
+ console.log(`audited the plugin-allowlist naming convention under ${root}`);
11461
+ if (violations.length > 0) {
11462
+ console.error("\u2717 Plugin-allowlist convention guard: drift found\n");
11463
+ for (const v of violations) {
11464
+ console.error(` ${v.file}
11465
+ ${v.message}`);
11466
+ }
11467
+ console.error("\nSee biffo-template#266, biffo-template#1545, tabsii-platform#863.");
11468
+ process.exit(1);
11469
+ }
11470
+ console.log(
11471
+ "\u2713 Plugin-allowlist convention guard: the allowlist glob matches the role name the naming modules build, and enabled_plugins still defaults to [] (fail-closed)"
11472
+ );
11473
+ }
11474
+
11339
11475
  // src/scripts/check-plugin-collisions.ts
11340
11476
  import { existsSync as existsSync39 } from "fs";
11341
- import { join as join47 } from "path";
11342
- import { execa as execa15 } from "execa";
11477
+ import { join as join48 } from "path";
11478
+ import { execa as execa16 } from "execa";
11343
11479
 
11344
11480
  // src/lib/plugin-collision-guard.ts
11345
11481
  import { existsSync as existsSync38, readdirSync as readdirSync20, statSync as statSync12 } from "fs";
11346
- import { join as join46 } from "path";
11482
+ import { join as join47 } from "path";
11347
11483
  var PYTEST_SPECIAL = /* @__PURE__ */ new Set(["conftest.py"]);
11348
11484
  var IGNORED_DIRS = /* @__PURE__ */ new Set([".venv", "node_modules", "__pycache__", ".git", "dist", "build"]);
11349
11485
  function subdirectories(dir) {
@@ -11351,19 +11487,19 @@ function subdirectories(dir) {
11351
11487
  return readdirSync20(dir).filter((entry) => {
11352
11488
  if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) return false;
11353
11489
  try {
11354
- return statSync12(join46(dir, entry)).isDirectory();
11490
+ return statSync12(join47(dir, entry)).isDirectory();
11355
11491
  } catch {
11356
11492
  return false;
11357
11493
  }
11358
11494
  });
11359
11495
  }
11360
11496
  function regularPackagesOf(pluginDir2) {
11361
- return subdirectories(pluginDir2).filter((name) => existsSync38(join46(pluginDir2, name, "__init__.py"))).sort();
11497
+ return subdirectories(pluginDir2).filter((name) => existsSync38(join47(pluginDir2, name, "__init__.py"))).sort();
11362
11498
  }
11363
11499
  function bareTestModulesOf(pluginDir2) {
11364
- const testsDir = join46(pluginDir2, "tests");
11500
+ const testsDir = join47(pluginDir2, "tests");
11365
11501
  if (!existsSync38(testsDir)) return [];
11366
- if (existsSync38(join46(testsDir, "__init__.py"))) return [];
11502
+ if (existsSync38(join47(testsDir, "__init__.py"))) return [];
11367
11503
  return readdirSync20(testsDir).filter((f) => f.endsWith(".py") && !PYTEST_SPECIAL.has(f)).sort();
11368
11504
  }
11369
11505
  function findCollisions(servicesDir, pluginDirs) {
@@ -11372,7 +11508,7 @@ function findCollisions(servicesDir, pluginDirs) {
11372
11508
  const gather = (kind, namesOf) => {
11373
11509
  const claims = /* @__PURE__ */ new Map();
11374
11510
  for (const plugin of plugins) {
11375
- for (const name of namesOf(join46(servicesDir, plugin))) {
11511
+ for (const name of namesOf(join47(servicesDir, plugin))) {
11376
11512
  claims.set(name, [...claims.get(name) ?? [], plugin]);
11377
11513
  }
11378
11514
  }
@@ -11409,8 +11545,8 @@ function formatCollisions(collisions) {
11409
11545
 
11410
11546
  // src/scripts/check-plugin-collisions.ts
11411
11547
  async function runPluginCollisionCheck() {
11412
- const root = (await execa15("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11413
- const servicesDir = join47(root, "services");
11548
+ const root = (await execa16("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11549
+ const servicesDir = join48(root, "services");
11414
11550
  if (!existsSync39(servicesDir)) {
11415
11551
  console.log("\u2713 plugin collision guard: no services/ directory \u2014 nothing to compare");
11416
11552
  return;
@@ -11428,11 +11564,11 @@ async function runPluginCollisionCheck() {
11428
11564
  }
11429
11565
 
11430
11566
  // src/scripts/check-plugin-terraform.ts
11431
- import { execa as execa16 } from "execa";
11567
+ import { execa as execa17 } from "execa";
11432
11568
 
11433
11569
  // src/lib/plugin-terraform-guard.ts
11434
- import { existsSync as existsSync40, readFileSync as readFileSync34, readdirSync as readdirSync21 } from "fs";
11435
- import { dirname as dirname10, join as join48, relative as relative8, sep as sep3 } from "path";
11570
+ import { existsSync as existsSync40, readFileSync as readFileSync35, readdirSync as readdirSync21 } from "fs";
11571
+ import { dirname as dirname10, join as join49, relative as relative8, sep as sep3 } from "path";
11436
11572
  var SKIP_DIRS3 = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
11437
11573
  var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
11438
11574
  function findPluginManifests(root) {
@@ -11447,9 +11583,9 @@ function findPluginManifests(root) {
11447
11583
  for (const entry of entries) {
11448
11584
  if (entry.isDirectory()) {
11449
11585
  if (SKIP_DIRS3.has(entry.name)) continue;
11450
- walk2(join48(dir, entry.name));
11586
+ walk2(join49(dir, entry.name));
11451
11587
  } else if (entry.isFile() && entry.name === PLUGIN_MANIFEST_FILE2) {
11452
- found.push(relative8(root, join48(dir, entry.name)).split(sep3).join("/"));
11588
+ found.push(relative8(root, join49(dir, entry.name)).split(sep3).join("/"));
11453
11589
  }
11454
11590
  }
11455
11591
  };
@@ -11459,7 +11595,7 @@ function findPluginManifests(root) {
11459
11595
  function readSubscriptions(absManifestPath) {
11460
11596
  let parsed;
11461
11597
  try {
11462
- parsed = JSON.parse(readFileSync34(absManifestPath, "utf8"));
11598
+ parsed = JSON.parse(readFileSync35(absManifestPath, "utf8"));
11463
11599
  } catch {
11464
11600
  return null;
11465
11601
  }
@@ -11474,14 +11610,14 @@ function readSubscriptions(absManifestPath) {
11474
11610
  }
11475
11611
  function checkPluginTerraform(root) {
11476
11612
  const violations = [];
11477
- const coreManifest = existsSync40(join48(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
11613
+ const coreManifest = existsSync40(join49(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
11478
11614
  for (const manifest of findPluginManifests(root)) {
11479
11615
  if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
11480
- const absManifest = join48(root, manifest);
11616
+ const absManifest = join49(root, manifest);
11481
11617
  const subscriptions = readSubscriptions(absManifest);
11482
11618
  if (subscriptions === null) continue;
11483
11619
  const pluginDir2 = dirname10(absManifest);
11484
- if (existsSync40(join48(pluginDir2, "terraform"))) continue;
11620
+ if (existsSync40(join49(pluginDir2, "terraform"))) continue;
11485
11621
  const relPluginDir = relative8(root, pluginDir2).split(sep3).join("/");
11486
11622
  violations.push({
11487
11623
  manifest,
@@ -11501,7 +11637,7 @@ function formatViolations(violations) {
11501
11637
 
11502
11638
  // src/scripts/check-plugin-terraform.ts
11503
11639
  async function runPluginTerraformCheck() {
11504
- const root = (await execa16("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11640
+ const root = (await execa17("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11505
11641
  const violations = checkPluginTerraform(root);
11506
11642
  if (violations.length > 0) {
11507
11643
  console.error("\u2717 plugin Terraform guard: event subscriptions with no infrastructure\n");
@@ -11513,12 +11649,12 @@ async function runPluginTerraformCheck() {
11513
11649
 
11514
11650
  // src/scripts/check-plugin-tool-supply.ts
11515
11651
  import { existsSync as existsSync42 } from "fs";
11516
- import { join as join50 } from "path";
11517
- import { execa as execa17 } from "execa";
11652
+ import { join as join51 } from "path";
11653
+ import { execa as execa18 } from "execa";
11518
11654
 
11519
11655
  // src/lib/plugin-tool-supply-audit.ts
11520
- import { existsSync as existsSync41, readFileSync as readFileSync35, readdirSync as readdirSync22, statSync as statSync13 } from "fs";
11521
- import { join as join49 } from "path";
11656
+ import { existsSync as existsSync41, readFileSync as readFileSync36, readdirSync as readdirSync22, statSync as statSync13 } from "fs";
11657
+ import { join as join50 } from "path";
11522
11658
 
11523
11659
  // src/lib/openrouter-model-snapshot.ts
11524
11660
  var OPENROUTER_MODEL_SNAPSHOT_FETCHED_AT = "2026-08-10T06:39:01Z";
@@ -11935,7 +12071,7 @@ function listDirs(root) {
11935
12071
  }
11936
12072
  return entries.filter((e) => {
11937
12073
  try {
11938
- return statSync13(join49(root, e)).isDirectory();
12074
+ return statSync13(join50(root, e)).isDirectory();
11939
12075
  } catch {
11940
12076
  return false;
11941
12077
  }
@@ -11951,7 +12087,7 @@ function walkFiles2(root, accept, skipDir) {
11951
12087
  return;
11952
12088
  }
11953
12089
  for (const entry of entries) {
11954
- const p = join49(dir, entry);
12090
+ const p = join50(dir, entry);
11955
12091
  let st;
11956
12092
  try {
11957
12093
  st = statSync13(p);
@@ -11977,14 +12113,14 @@ function pluginPythonFiles(pluginDir2) {
11977
12113
  );
11978
12114
  }
11979
12115
  function pluginTerraformFiles(pluginDir2) {
11980
- const tfDir = join49(pluginDir2, "terraform");
12116
+ const tfDir = join50(pluginDir2, "terraform");
11981
12117
  let entries;
11982
12118
  try {
11983
12119
  entries = readdirSync22(tfDir);
11984
12120
  } catch {
11985
12121
  return [];
11986
12122
  }
11987
- return entries.filter((e) => e.endsWith(".tf")).map((e) => join49(tfDir, e)).sort();
12123
+ return entries.filter((e) => e.endsWith(".tf")).map((e) => join50(tfDir, e)).sort();
11988
12124
  }
11989
12125
  function extractManifestTools(manifestText) {
11990
12126
  let parsed;
@@ -12236,8 +12372,8 @@ function isSnapshotStale(fetchedAt, now) {
12236
12372
  function normalizeModelId(id) {
12237
12373
  return id.endsWith(":online") ? id.slice(0, -":online".length) : id;
12238
12374
  }
12239
- var CONFIG_PY_PATH = join49("services", "api", "src", "api", "config.py");
12240
- var ORCHESTRATION_SCHEMA_PATH = join49(
12375
+ var CONFIG_PY_PATH = join50("services", "api", "src", "api", "config.py");
12376
+ var ORCHESTRATION_SCHEMA_PATH = join50(
12241
12377
  "services",
12242
12378
  "api",
12243
12379
  "src",
@@ -12249,8 +12385,8 @@ function auditDeclaredModelIds(repoRoot, options = {}) {
12249
12385
  const knownModelIds = options.knownModelIds ?? OPENROUTER_MODEL_IDS;
12250
12386
  const snapshotFetchedAt = options.snapshotFetchedAt ?? OPENROUTER_MODEL_SNAPSHOT_FETCHED_AT;
12251
12387
  const now = options.now ?? /* @__PURE__ */ new Date();
12252
- const configPath = join49(repoRoot, CONFIG_PY_PATH);
12253
- const orchestrationPath = join49(repoRoot, ORCHESTRATION_SCHEMA_PATH);
12388
+ const configPath = join50(repoRoot, CONFIG_PY_PATH);
12389
+ const orchestrationPath = join50(repoRoot, ORCHESTRATION_SCHEMA_PATH);
12254
12390
  const configMissing = !existsSync41(configPath);
12255
12391
  const orchestrationSchemaMissing = !existsSync41(orchestrationPath);
12256
12392
  const knownSet = new Set(knownModelIds);
@@ -12270,13 +12406,13 @@ function auditDeclaredModelIds(repoRoot, options = {}) {
12270
12406
  };
12271
12407
  let settingsBlind = false;
12272
12408
  if (!configMissing) {
12273
- const settingsFields = extractSettingsModelFields(readFileSync35(configPath, "utf8"));
12409
+ const settingsFields = extractSettingsModelFields(readFileSync36(configPath, "utf8"));
12274
12410
  if (settingsFields.length === 0) settingsBlind = true;
12275
12411
  for (const { field, value } of settingsFields) record(`${CONFIG_PY_PATH}#${field}`, value);
12276
12412
  }
12277
12413
  let curatedFieldsBlind = false;
12278
12414
  if (!orchestrationSchemaMissing) {
12279
- const curated = extractCuratedModelFields(readFileSync35(orchestrationPath, "utf8"));
12415
+ const curated = extractCuratedModelFields(readFileSync36(orchestrationPath, "utf8"));
12280
12416
  if (curated.rawFieldCount > 0 && curated.fields.every((f) => f.defaultValue === null && f.optionValues.length === 0)) {
12281
12417
  curatedFieldsBlind = true;
12282
12418
  }
@@ -12323,7 +12459,7 @@ function auditDeclaredModelIds(repoRoot, options = {}) {
12323
12459
  function discoverPluginDirs(pluginsRoot) {
12324
12460
  return listDirs(pluginsRoot).filter((name) => {
12325
12461
  try {
12326
- return statSync13(join49(pluginsRoot, name, "biffo.plugin.json")).isFile();
12462
+ return statSync13(join50(pluginsRoot, name, "biffo.plugin.json")).isFile();
12327
12463
  } catch {
12328
12464
  return false;
12329
12465
  }
@@ -12336,8 +12472,8 @@ function auditPluginToolSupply(pluginsRoot) {
12336
12472
  let terraformBlind = false;
12337
12473
  let totalDeclaredTools = 0;
12338
12474
  for (const name of pluginNames) {
12339
- const pluginDir2 = join49(pluginsRoot, name);
12340
- const manifestText = readFileSync35(join49(pluginDir2, "biffo.plugin.json"), "utf8");
12475
+ const pluginDir2 = join50(pluginsRoot, name);
12476
+ const manifestText = readFileSync36(join50(pluginDir2, "biffo.plugin.json"), "utf8");
12341
12477
  const manifest = extractManifestTools(manifestText);
12342
12478
  if (manifest.parseError) {
12343
12479
  findings.push({
@@ -12355,13 +12491,13 @@ function auditPluginToolSupply(pluginsRoot) {
12355
12491
  totalDeclaredTools += manifest.tools.length;
12356
12492
  const pySources = pluginPythonFiles(pluginDir2).map((f) => ({
12357
12493
  file: f,
12358
- text: readFileSync35(f, "utf8")
12494
+ text: readFileSync36(f, "utf8")
12359
12495
  }));
12360
12496
  const resolver = buildSymbolResolver(pySources);
12361
12497
  const registry = extractToolRegistryEntries(pySources, resolver);
12362
12498
  if (registry.rawToolDefinitionCount > 0 && registry.entries.length === 0) registryBlind = true;
12363
12499
  const tfFiles = pluginTerraformFiles(pluginDir2);
12364
- const tfText = tfFiles.map((f) => readFileSync35(f, "utf8")).join("\n");
12500
+ const tfText = tfFiles.map((f) => readFileSync36(f, "utf8")).join("\n");
12365
12501
  const terraform = extractTerraformEnvKeys(tfText);
12366
12502
  if (terraform.rawMarkerCount > 0 && terraform.resolvedBlockCount === 0) terraformBlind = true;
12367
12503
  for (const toolName of manifest.tools) {
@@ -12435,7 +12571,7 @@ function auditPluginToolSupply(pluginsRoot) {
12435
12571
  requiredEnvVars: envResult.envVars,
12436
12572
  missingEnvVars: anyWired ? [] : envResult.envVars,
12437
12573
  status: anyWired ? "ok" : "missing-env",
12438
- detail: anyWired ? `${entry.predicate}() is satisfiable: at least one of ${JSON.stringify(envResult.envVars)} is wired in Terraform` : `${entry.predicate}() reads ${JSON.stringify(envResult.envVars)} \u2014 NONE of these are wired by any environment_variables block under ${join49(pluginDir2, "terraform")}, so this deployment can never supply it`
12574
+ detail: anyWired ? `${entry.predicate}() is satisfiable: at least one of ${JSON.stringify(envResult.envVars)} is wired in Terraform` : `${entry.predicate}() reads ${JSON.stringify(envResult.envVars)} \u2014 NONE of these are wired by any environment_variables block under ${join50(pluginDir2, "terraform")}, so this deployment can never supply it`
12439
12575
  });
12440
12576
  }
12441
12577
  }
@@ -12466,9 +12602,9 @@ function auditPluginToolSupply(pluginsRoot) {
12466
12602
 
12467
12603
  // src/scripts/check-plugin-tool-supply.ts
12468
12604
  async function runPluginToolSupplyCheck() {
12469
- const root = (await execa17("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12605
+ const root = (await execa18("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12470
12606
  let allOk = true;
12471
- const pluginsRoot = join50(root, "services", "_plugins");
12607
+ const pluginsRoot = join51(root, "services", "_plugins");
12472
12608
  if (!existsSync42(pluginsRoot)) {
12473
12609
  console.log("\u2713 plugin tool-supply guard: no services/_plugins/ \u2014 nothing to audit");
12474
12610
  } else {
@@ -12499,7 +12635,7 @@ async function runPluginToolSupplyCheck() {
12499
12635
  console.log(`\u2713 plugin tool-supply guard: ${report.summary}`);
12500
12636
  }
12501
12637
  }
12502
- const servicesApiRoot = join50(root, "services", "api");
12638
+ const servicesApiRoot = join51(root, "services", "api");
12503
12639
  if (!existsSync42(servicesApiRoot)) {
12504
12640
  console.log("\u2713 plugin model-id guard: no services/api/ \u2014 nothing to audit");
12505
12641
  } else {
@@ -12546,7 +12682,7 @@ async function runPluginToolSupplyCheck() {
12546
12682
  }
12547
12683
 
12548
12684
  // src/scripts/check-release-subject.ts
12549
- import { execa as execa18 } from "execa";
12685
+ import { execa as execa19 } from "execa";
12550
12686
 
12551
12687
  // src/lib/release-version.ts
12552
12688
  var MINOR_TYPES = /* @__PURE__ */ new Set(["feat"]);
@@ -12583,7 +12719,7 @@ async function fetchPrTitleViaGh({
12583
12719
  PR_NUMBER,
12584
12720
  GH_REPO
12585
12721
  }) {
12586
- const { stdout } = await execa18(
12722
+ const { stdout } = await execa19(
12587
12723
  "gh",
12588
12724
  ["pr", "view", PR_NUMBER, "--repo", GH_REPO, "--json", "title", "--jq", ".title"],
12589
12725
  { env: { ...process.env, GH_TOKEN } }
@@ -12619,7 +12755,7 @@ async function resolveReleaseSubject({
12619
12755
  );
12620
12756
  }
12621
12757
  }
12622
- return (await execa18("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
12758
+ return (await execa19("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
12623
12759
  }
12624
12760
  async function runReleaseSubjectCheck(argv) {
12625
12761
  const base = process.env["GITHUB_BASE_REF"] ?? argv[0];
@@ -12627,9 +12763,9 @@ async function runReleaseSubjectCheck(argv) {
12627
12763
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
12628
12764
  process.exit(2);
12629
12765
  }
12630
- const root = (await execa18("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12631
- await execa18("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
12632
- const { stdout } = await execa18("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
12766
+ const root = (await execa19("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12767
+ await execa19("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
12768
+ const { stdout } = await execa19("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
12633
12769
  cwd: root
12634
12770
  });
12635
12771
  const changedFiles = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
@@ -12678,12 +12814,12 @@ async function runReleaseSubjectCheck(argv) {
12678
12814
 
12679
12815
  // src/scripts/check-skeleton-drift.ts
12680
12816
  import { existsSync as existsSync43, readdirSync as readdirSync24 } from "fs";
12681
- import { join as join52 } from "path";
12682
- import { execa as execa19 } from "execa";
12817
+ import { join as join53 } from "path";
12818
+ import { execa as execa20 } from "execa";
12683
12819
 
12684
12820
  // src/lib/skeleton-drift-guard.ts
12685
- import { readFileSync as readFileSync36, readdirSync as readdirSync23, statSync as statSync14 } from "fs";
12686
- import { join as join51 } from "path";
12821
+ import { readFileSync as readFileSync37, readdirSync as readdirSync23, statSync as statSync14 } from "fs";
12822
+ import { join as join52 } from "path";
12687
12823
  var isWorkflow = (rel) => rel.startsWith(".github/workflows/") && (rel.endsWith(".yml") || rel.endsWith(".yaml"));
12688
12824
  var isRootLayout = (rel) => rel.endsWith("src/app/layout.tsx");
12689
12825
  var uncommented = (contents) => contents.split("\n").filter((line) => !/^\s*(\/\/|\/\*|\*)/.test(line)).join("\n");
@@ -12747,7 +12883,7 @@ function walk(dir, base = dir) {
12747
12883
  }
12748
12884
  for (const entry of entries) {
12749
12885
  if (entry === ".venv" || entry === "node_modules" || entry === ".git") continue;
12750
- const abs = join51(dir, entry);
12886
+ const abs = join52(dir, entry);
12751
12887
  let isDir;
12752
12888
  try {
12753
12889
  isDir = statSync14(abs).isDirectory();
@@ -12769,7 +12905,7 @@ function auditSkeleton(skeletonRoot, name, rules = SKELETON_RULES) {
12769
12905
  if (!rule.appliesTo(rel)) continue;
12770
12906
  let contents;
12771
12907
  try {
12772
- contents = readFileSync36(join51(skeletonRoot, rel), "utf8");
12908
+ contents = readFileSync37(join52(skeletonRoot, rel), "utf8");
12773
12909
  } catch {
12774
12910
  continue;
12775
12911
  }
@@ -12798,23 +12934,23 @@ function formatViolations2(violations) {
12798
12934
 
12799
12935
  // src/scripts/check-skeleton-drift.ts
12800
12936
  function discoverSkeletons(root) {
12801
- const skeletonsDir = join52(root, "_skeletons");
12937
+ const skeletonsDir = join53(root, "_skeletons");
12802
12938
  let entries;
12803
12939
  try {
12804
12940
  entries = readdirSync24(skeletonsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
12805
12941
  } catch {
12806
12942
  return [];
12807
12943
  }
12808
- return entries.filter((name) => existsSync43(join52(skeletonsDir, name, ".github", "workflows", "ci.yml"))).sort();
12944
+ return entries.filter((name) => existsSync43(join53(skeletonsDir, name, ".github", "workflows", "ci.yml"))).sort();
12809
12945
  }
12810
12946
  async function runSkeletonDriftCheck() {
12811
- const root = (await execa19("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12947
+ const root = (await execa20("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12812
12948
  const skeletons = discoverSkeletons(root);
12813
12949
  let filesConsidered = 0;
12814
12950
  for (const name of skeletons) {
12815
- const skeletonRoot = join52(root, "_skeletons", name);
12951
+ const skeletonRoot = join53(root, "_skeletons", name);
12816
12952
  filesConsidered += findWorkflowFiles(skeletonRoot).length;
12817
- if (existsSync43(join52(skeletonRoot, "apps", "frontend", "src", "app", "layout.tsx"))) {
12953
+ if (existsSync43(join53(skeletonRoot, "apps", "frontend", "src", "app", "layout.tsx"))) {
12818
12954
  filesConsidered += 1;
12819
12955
  }
12820
12956
  }
@@ -12828,7 +12964,7 @@ async function runSkeletonDriftCheck() {
12828
12964
  process.exit(1);
12829
12965
  }
12830
12966
  const violations = skeletons.flatMap(
12831
- (name) => auditSkeleton(join52(root, "_skeletons", name), name)
12967
+ (name) => auditSkeleton(join53(root, "_skeletons", name), name)
12832
12968
  );
12833
12969
  if (violations.length > 0) {
12834
12970
  console.error("\u2717 Skeleton-drift guard: drift found between this repo and its scaffolding\n");
@@ -12840,9 +12976,9 @@ async function runSkeletonDriftCheck() {
12840
12976
  }
12841
12977
 
12842
12978
  // src/scripts/check-terraform-input.ts
12843
- import { execa as execa20 } from "execa";
12979
+ import { execa as execa21 } from "execa";
12844
12980
  async function runTerraformInputCheck() {
12845
- const root = (await execa20("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12981
+ const root = (await execa21("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
12846
12982
  const files = findWorkflowFiles(root);
12847
12983
  console.log(`audited ${files.length} workflow file(s) under ${root}`);
12848
12984
  if (files.length === 0) {
@@ -12865,7 +13001,7 @@ async function runTerraformInputCheck() {
12865
13001
 
12866
13002
  // src/commands/check.ts
12867
13003
  var checkCommand = new Command23("check").description(
12868
- "Repo guards (ownership, release subject, plugin terraform, plugin collisions, eventbridge-log-permissions, plugin-tool-supply, core-direct-paths, cognito-invite-template, lambda-output, pipe-trap, codeql-suppression, skeleton-drift, terraform-input) run in CI and git hooks, plus out-of-band audits (branch protection)"
13004
+ "Repo guards (ownership, release subject, plugin terraform, plugin collisions, eventbridge-log-permissions, plugin-tool-supply, core-direct-paths, cognito-invite-template, lambda-output, pipe-trap, codeql-suppression, skeleton-drift, terraform-input, plugin-allowlist-convention) run in CI and git hooks, plus out-of-band audits (branch protection)"
12869
13005
  );
12870
13006
  checkCommand.command("ownership").description("Refuse changes to template-owned paths in an instance (#370)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").option("--staged <messageFile>", "Check staged changes instead of a branch diff (commit hook)").allowExcessArguments(true).action(async () => {
12871
13007
  await runOwnershipCheck(rawArgsAfter("ownership"));
@@ -12879,6 +13015,11 @@ checkCommand.command("plugin-collisions").description("Refuse two vendored plugi
12879
13015
  checkCommand.command("plugin-terraform").description("Verify every template-owned plugin declaring infra ships a Terraform module").action(async () => {
12880
13016
  await runPluginTerraformCheck();
12881
13017
  });
13018
+ checkCommand.command("plugin-allowlist-convention").description(
13019
+ "Refuse the ADR-0009 service-principal allowlist glob drifting from the IAM role name modules/cloud/aws/compute + modules/plugins/_template actually build (#266) \u2014 terraform validate is silent on this because the allowlist never references either naming module by design, so a rename would leave every plugin call rejected with no signal before a real deploy hits it. tabsii-platform#863 is this exact failure shape already reaching production, via a hand-maintained allowlist that simply omitted the plugin host."
13020
+ ).action(async () => {
13021
+ await runPluginAllowlistConventionCheck();
13022
+ });
12882
13023
  checkCommand.command("adr-numbering").description(
12883
13024
  "Refuse two ADRs in this repo's own docs/ADR/ claiming the same number (tabsii-platform#449)"
12884
13025
  ).action(async () => {
@@ -12951,8 +13092,8 @@ function rawArgsAfter(subcommand) {
12951
13092
  }
12952
13093
 
12953
13094
  // src/commands/doctor.ts
12954
- import { existsSync as existsSync44, readFileSync as readFileSync37 } from "fs";
12955
- import { join as join53, resolve as resolve18 } from "path";
13095
+ import { existsSync as existsSync44, readFileSync as readFileSync38 } from "fs";
13096
+ import { join as join54, resolve as resolve19 } from "path";
12956
13097
  import chalk21 from "chalk";
12957
13098
  import { Command as Command24 } from "commander";
12958
13099
 
@@ -13080,7 +13221,7 @@ var INTEGRATION_BRANCH = "dev";
13080
13221
  var doctorCommand = new Command24("doctor").description(
13081
13222
  "Report repo-state conditions that make everything read from this checkout unreliable"
13082
13223
  ).option("--cwd <path>", "Repo root to inspect (defaults to the current directory)").option("--no-fetch", "Skip the fetch; report against refs as they already are locally").action(async (options) => {
13083
- const cwd = options.cwd ? resolve18(options.cwd) : process.cwd();
13224
+ const cwd = options.cwd ? resolve19(options.cwd) : process.cwd();
13084
13225
  try {
13085
13226
  const findings = await runDoctor({ cwd, fetch: options.fetch !== false });
13086
13227
  printFindings(findings);
@@ -13127,10 +13268,10 @@ async function runDoctor(options, deps = { git: new GitAdapter() }) {
13127
13268
  return runDoctorChecks(facts);
13128
13269
  }
13129
13270
  function readLocalCoreVersion(cwd) {
13130
- const path = join53(cwd, INSTANCE_CORE_FILE);
13271
+ const path = join54(cwd, INSTANCE_CORE_FILE);
13131
13272
  if (!existsSync44(path)) return null;
13132
13273
  try {
13133
- return parseCoreRecord(readFileSync37(path, "utf8"));
13274
+ return parseCoreRecord(readFileSync38(path, "utf8"));
13134
13275
  } catch {
13135
13276
  return null;
13136
13277
  }
@@ -13145,10 +13286,10 @@ function parseCoreRecord(contents) {
13145
13286
  }
13146
13287
  }
13147
13288
  function readFossil(cwd) {
13148
- const path = join53(cwd, CORE_VERSION_FILE);
13289
+ const path = join54(cwd, CORE_VERSION_FILE);
13149
13290
  if (!existsSync44(path)) return null;
13150
13291
  try {
13151
- const value = readFileSync37(path, "utf8").trim();
13292
+ const value = readFileSync38(path, "utf8").trim();
13152
13293
  return value === "" ? null : value;
13153
13294
  } catch {
13154
13295
  return null;
@@ -13598,11 +13739,11 @@ import { Command as Command26 } from "commander";
13598
13739
 
13599
13740
  // src/lib/packaged-scripts.ts
13600
13741
  import { existsSync as existsSync45 } from "fs";
13601
- import { dirname as dirname11, join as join54 } from "path";
13742
+ import { dirname as dirname11, join as join55 } from "path";
13602
13743
  function findPackagedScript(startDir, relativePath) {
13603
13744
  let dir = startDir;
13604
13745
  for (; ; ) {
13605
- const candidate = join54(dir, relativePath);
13746
+ const candidate = join55(dir, relativePath);
13606
13747
  if (existsSync45(candidate)) return candidate;
13607
13748
  const parent = dirname11(dir);
13608
13749
  if (parent === dir) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.278.2",
3
+ "version": "0.278.3",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",