@biffo/cli 0.278.1 → 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.
- package/_skeletons/plugin-template/web-admin/src/lib/auth.test.ts +127 -0
- package/_skeletons/plugin-template/web-admin/src/lib/auth.ts +28 -17
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +62 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +28 -17
- package/dist/index.js +226 -85
- package/package.json +1 -1
- package/scripts/practices-metrics.mjs +46 -0
|
@@ -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
|
|
23
|
+
let poolPromise: Promise<CognitoUserPool | null> | null = null
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
66
|
+
function getUserPool(): Promise<CognitoUserPool | null> {
|
|
67
|
+
poolPromise ??= (async () => {
|
|
68
|
+
const identity = await resolveCoreIdentity()
|
|
69
|
+
if (!identity) return null
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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:
|
|
10722
|
-
if (existsSync46(messageFile)) commitMessage =
|
|
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
|
|
11342
|
-
import { execa as
|
|
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
|
|
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(
|
|
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(
|
|
11497
|
+
return subdirectories(pluginDir2).filter((name) => existsSync38(join47(pluginDir2, name, "__init__.py"))).sort();
|
|
11362
11498
|
}
|
|
11363
11499
|
function bareTestModulesOf(pluginDir2) {
|
|
11364
|
-
const testsDir =
|
|
11500
|
+
const testsDir = join47(pluginDir2, "tests");
|
|
11365
11501
|
if (!existsSync38(testsDir)) return [];
|
|
11366
|
-
if (existsSync38(
|
|
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(
|
|
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
|
|
11413
|
-
const servicesDir =
|
|
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
|
|
11567
|
+
import { execa as execa17 } from "execa";
|
|
11432
11568
|
|
|
11433
11569
|
// src/lib/plugin-terraform-guard.ts
|
|
11434
|
-
import { existsSync as existsSync40, readFileSync as
|
|
11435
|
-
import { dirname as dirname10, join as
|
|
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(
|
|
11586
|
+
walk2(join49(dir, entry.name));
|
|
11451
11587
|
} else if (entry.isFile() && entry.name === PLUGIN_MANIFEST_FILE2) {
|
|
11452
|
-
found.push(relative8(root,
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
11517
|
-
import { execa as
|
|
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
|
|
11521
|
-
import { join as
|
|
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(
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
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 =
|
|
12240
|
-
var ORCHESTRATION_SCHEMA_PATH =
|
|
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 =
|
|
12253
|
-
const orchestrationPath =
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
12340
|
-
const manifestText =
|
|
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:
|
|
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) =>
|
|
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 ${
|
|
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
|
|
12605
|
+
const root = (await execa18("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
|
|
12470
12606
|
let allOk = true;
|
|
12471
|
-
const pluginsRoot =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
12631
|
-
await
|
|
12632
|
-
const { stdout } = await
|
|
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
|
|
12682
|
-
import { execa as
|
|
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
|
|
12686
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
12951
|
+
const skeletonRoot = join53(root, "_skeletons", name);
|
|
12816
12952
|
filesConsidered += findWorkflowFiles(skeletonRoot).length;
|
|
12817
|
-
if (existsSync43(
|
|
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(
|
|
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
|
|
12979
|
+
import { execa as execa21 } from "execa";
|
|
12844
12980
|
async function runTerraformInputCheck() {
|
|
12845
|
-
const root = (await
|
|
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
|
|
12955
|
-
import { join as
|
|
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 ?
|
|
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 =
|
|
13271
|
+
const path = join54(cwd, INSTANCE_CORE_FILE);
|
|
13131
13272
|
if (!existsSync44(path)) return null;
|
|
13132
13273
|
try {
|
|
13133
|
-
return parseCoreRecord(
|
|
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 =
|
|
13289
|
+
const path = join54(cwd, CORE_VERSION_FILE);
|
|
13149
13290
|
if (!existsSync44(path)) return null;
|
|
13150
13291
|
try {
|
|
13151
|
-
const value =
|
|
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
|
|
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 =
|
|
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
|
@@ -997,6 +997,52 @@ export const RACE_THRESHOLD_MINUTES = 10
|
|
|
997
997
|
* just before the window opened is not in `prs` to be counted. The bias is
|
|
998
998
|
* one-directional and toward zero, so a rise is always real.
|
|
999
999
|
*
|
|
1000
|
+
* ## `racedShare` is not portable across repos with different CI speed (#1419)
|
|
1001
|
+
*
|
|
1002
|
+
* `RACE_THRESHOLD_MINUTES` is an **absolute** 10 minutes, the same number for
|
|
1003
|
+
* every repo regardless of how long that repo's own CI takes to reach green.
|
|
1004
|
+
* That makes the threshold a function of pipeline latency as much as of
|
|
1005
|
+
* contention: a repo whose median time-to-green already exceeds 10 minutes
|
|
1006
|
+
* crosses `racedShare`'s numerator condition on wait time alone, before any
|
|
1007
|
+
* question of whether `dev` moved underneath it.
|
|
1008
|
+
*
|
|
1009
|
+
* Measured live 2026-08-12, four repos, 7-day window, `strict: false` on
|
|
1010
|
+
* three of the four (the fourth, `biffo-plugin-idea-scout`, `true`):
|
|
1011
|
+
*
|
|
1012
|
+
* | repo | runner | `greenToMergeP50Minutes` | `repushRate` | `racedShare` | `staleMergeShare` |
|
|
1013
|
+
* | --- | --- | ---: | ---: | ---: | ---: |
|
|
1014
|
+
* | `biffo-template` | hosted | 4.1 | 15.0% | 12.6% | 18.1% |
|
|
1015
|
+
* | `biffo-platform` | hosted | 4.4 | 12.5% | 12.5% | 0% |
|
|
1016
|
+
* | `biffo-plugin-idea-scout` | hosted | 7.3 | 46.2% | 23.1% | 0% |
|
|
1017
|
+
* | `tabsii-platform` | **self-hosted, spot** | **14.7** | **42.7%** | **41.3%** | 7.3% |
|
|
1018
|
+
*
|
|
1019
|
+
* `tabsii-platform` is the only repo of the four on self-hosted (spot) runner
|
|
1020
|
+
* capacity, and its median green-to-merge lag (14.7min) already clears the
|
|
1021
|
+
* fixed 10-minute threshold by itself — consistent with the runner-queueing
|
|
1022
|
+
* cost recorded elsewhere in this estate for that fleet. `racedShare` tracks
|
|
1023
|
+
* `repushRate` far more tightly across this table than it tracks `strict`
|
|
1024
|
+
* (three of the four rows are `strict: false` and still span 12.5%–41.3%),
|
|
1025
|
+
* which reconfirms H4's amendment to H3: *"`racedShare` counts PRs that sat
|
|
1026
|
+
* green over ten minutes and were repushed. It is arithmetically a function of
|
|
1027
|
+
* how many times you push."*
|
|
1028
|
+
*
|
|
1029
|
+
* The clincher against reading `racedShare` as *this repo has more genuine
|
|
1030
|
+
* integration contention*: `staleMergeShare` — the metric that actually
|
|
1031
|
+
* detects whether `dev` moved between a PR's last green run and its merge —
|
|
1032
|
+
* is **lower** at `tabsii-platform` (7.3%) than at `biffo-template` (18.1%).
|
|
1033
|
+
* If `tabsii-platform`'s elevated `racedShare` reflected more real racing
|
|
1034
|
+
* against a moving `dev`, `staleMergeShare` should read higher there too, not
|
|
1035
|
+
* lower. It does not, so the elevated reading is better read as this repo's
|
|
1036
|
+
* own CI/runner latency and repush volume crossing an absolute threshold, not
|
|
1037
|
+
* as more contention.
|
|
1038
|
+
*
|
|
1039
|
+
* **Practical consequence: never read `racedShare` as a bare cross-repo
|
|
1040
|
+
* defect rate.** Read it beside `greenToMergeP50Minutes` (this repo's own
|
|
1041
|
+
* pipeline speed relative to the fixed threshold) and `repushRate` (the
|
|
1042
|
+
* arithmetic driver), and treat `staleMergeShare` as the tie-breaker when the
|
|
1043
|
+
* two disagree about whether a high `racedShare` is genuine contention. The
|
|
1044
|
+
* dashboard's tooltip on this cell carries the same note.
|
|
1045
|
+
*
|
|
1000
1046
|
* @param {Array<{createdAt: string, mergedAt: string | null, headRefName: string, baseRefName?: string}>} prs
|
|
1001
1047
|
* @param {Map<string, Array<Record<string, unknown>>>} runsByBranch
|
|
1002
1048
|
*/
|