-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprimordials.ts
More file actions
419 lines (398 loc) · 13.8 KB
/
primordials.ts
File metadata and controls
419 lines (398 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
/**
* @file Primordials drift check — generic core. Each fleet repo that
* destructures from Node's internal `primordials` global needs to keep its
* usage shape-aligned with socket-lib's userland mirror
* (`@socketsecurity/lib/primordials`). This module is the parser + diff
* engine; per-repo policy (which dirs to scan, naming aliases, allowlist)
* lives in a config the caller supplies. Used by the `socket-lib check
* primordials` CLI subcommand. Kept importable as a library so repos with
* bespoke needs can compose it directly without going through the CLI. The
* flow:
*
* 1. Walk the configured `scanDirs` for `*.js` files.
* 2. From each file, extract names from every `const { Foo, Bar } = primordials`
* destructure.
* 3. Read socket-lib's `primordials/` directory (sibling clone) or
* `primordials/*.d.ts` (installed `node_modules`) and pull every exported
* name across all leaves.
* 4. Diff: every destructured name must be either (a) in socket-lib verbatim,
* (b) in socket-lib via the configured alias map, or (c) in the configured
* node-internal-only allowlist. Findings come back classified so callers
* can render or fail-CI on specific kinds.
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import path from 'node:path'
import { joinOr } from '../arrays/join'
import { ErrorCtor } from '../primordials/error'
// ── Config ──────────────────────────────────────────────────────────
export interface PrimordialsCheckConfig {
/**
* Repo-relative directories to scan recursively for `*.js` files containing
* `primordials` destructures. Each entry is resolved against `repoRoot`.
*/
readonly scanDirs: readonly string[]
/**
* Map from the source name a repo destructures (e.g. `Array`) to the
* socket-lib export name it should resolve to (e.g. `ArrayCtor`). socket-lib
* uses the `Ctor` suffix to avoid shadowing globals; repos that need the
* original name go through the alias.
*/
readonly aliasMap: ReadonlyMap<string, string>
/**
* Names that exist only in Node's internal `primordials` and are
* intentionally NOT mirrored to socket-lib. Adding to this set is a
* deliberate decision per name.
*/
readonly nodeInternalOnly: ReadonlySet<string>
/**
* Override the auto-resolution of socket-lib's primordials source. Useful for
* tests; production callers should leave this undefined so the resolver picks
* sibling clone → installed `node_modules`.
*/
readonly socketLibPrimordialsPath?: string | undefined
/**
* Repo root used to resolve `scanDirs` and to anchor the sibling-clone
* fallback (`<repoRoot>/../socket-lib/...`). Defaults to `process.cwd()`.
*/
readonly repoRoot?: string | undefined
}
// ── Findings ────────────────────────────────────────────────────────
export interface PrimordialsFinding {
readonly kind: 'unmapped' | 'missing-from-socket-lib'
readonly name: string
readonly files: readonly string[]
readonly hint: string
}
export interface PrimordialsCheckResult {
readonly used: ReadonlySet<string>
readonly usedToFiles: ReadonlyMap<string, readonly string[]>
readonly socketLibNames: ReadonlySet<string>
readonly findings: readonly PrimordialsFinding[]
}
// ── Source parsing ──────────────────────────────────────────────────
const NAME_HEAD_RE = /^([A-Za-z_$][A-Za-z0-9_$]*)/
/**
* Run the primordials drift check against the configured repo. Returns the full
* result including raw inputs (used names, lib exports) so renderers can show
* context, plus a sorted list of findings classified by kind.
*/
export function checkPrimordials(
config: PrimordialsCheckConfig,
): PrimordialsCheckResult {
const repoRoot = config.repoRoot ?? process.cwd()
// Collect the repo's primordial names + which files use them.
const used = new Set<string>()
const usedToFiles = new Map<string, string[]>()
for (const dir of config.scanDirs) {
const fullDir = path.resolve(repoRoot, dir)
const jsFiles = collectJsFiles(fullDir)
for (const file of jsFiles) {
let src: string
try {
src = readFileSync(file, 'utf8')
// readFileSync rarely throws on files we just enumerated; the
// includes()-false and names-empty arms fire only on files
// that don't actually destructure primordials.
/* c8 ignore start */
} catch {
continue
}
/* c8 ignore stop */
if (!src.includes('primordials')) {
continue
}
const names = extractPrimordialsNames(src)
if (names.length === 0) {
continue
}
const rel = path.relative(repoRoot, file)
for (const name of names) {
used.add(name)
const arr = usedToFiles.get(name) ?? []
if (!arr.includes(rel)) {
arr.push(rel)
}
usedToFiles.set(name, arr)
}
}
}
// Read socket-lib's exported names. The resolver returns either a
// file (legacy single-file layout) or a directory (post-split).
const socketLibPath = resolveSocketLibPrimordials(config)
const socketLibNames = readSocketLibPrimordialNames(socketLibPath)
// Diff.
const findings: PrimordialsFinding[] = []
for (const name of [...used].sort()) {
if (config.nodeInternalOnly.has(name)) {
continue
}
if (socketLibNames.has(name)) {
continue
}
const aliased = config.aliasMap.get(name)
// Aliased + missing/present sub-arms exercised in tests, but the
// `usedToFiles.get(name) ?? []` defensive fallback fires only when
// a name is in `used` but not `usedToFiles` (impossible by
// construction).
/* c8 ignore start */
if (aliased) {
if (socketLibNames.has(aliased)) {
continue
}
findings.push({
kind: 'missing-from-socket-lib',
name,
files: usedToFiles.get(name) ?? [],
hint:
`\`${name}\` is mapped to socket-lib's \`${aliased}\`, but ` +
`\`${aliased}\` is not exported. Add \`export const ${aliased} = ${name}\` ` +
'to the appropriate leaf under socket-lib/src/primordials/.',
})
continue
}
/* c8 ignore stop */
findings.push({
kind: 'unmapped',
name,
files: usedToFiles.get(name) ?? [],
hint:
`\`${name}\` is destructured from \`primordials\` but no ` +
`socket-lib mapping exists. Pick one: ` +
joinOr([
`add \`${name}\` to the appropriate leaf under socket-lib/src/primordials/`,
`add a \`${name}\` → \`<libName>\` entry to the alias map`,
`add \`${name}\` to nodeInternalOnly (if Node-internal only)`,
]) +
'.',
})
}
return {
used,
usedToFiles,
socketLibNames,
findings,
}
}
/**
* Recursively collect every `*.js` file under `dir`.
*/
export function collectJsFiles(dir: string): string[] {
const out: string[] = []
if (!existsSync(dir)) {
return out
}
const stack = [dir]
while (stack.length > 0) {
const cur = stack.pop()!
let entries: string[]
try {
entries = readdirSync(cur)
} catch {
continue
}
for (const name of entries) {
const full = path.join(cur, name)
let stat
try {
stat = statSync(full)
} catch {
continue
}
if (stat.isDirectory()) {
stack.push(full)
} else if (stat.isFile() && full.endsWith('.js')) {
out.push(full)
}
}
}
return out
}
/**
* Pull every `const { … } = primordials` destructure body out of `src`.
* Comments are stripped first so commentary inside a destructure doesn't leak
* into captured names. The body regex disallows nested `}`, which is safe after
* the comment-strip pass — destructures themselves don't contain `}`.
*/
export function extractPrimordialsNames(src: string): string[] {
const cleaned = stripComments(src)
const re = /const\s*\{\s*([^}]*?)\}\s*=\s*primordials\b/g
const out: string[] = []
let m: RegExpExecArray | null
while ((m = re.exec(cleaned)) !== null) {
for (const raw of m[1]!.split(',')) {
const trimmed = raw.trim()
if (!trimmed) {
continue
}
// `Foo: BarAlias` keeps `Foo` (the source name on the LHS).
const nameMatch = NAME_HEAD_RE.exec(trimmed)
// nameMatch null arm fires on malformed export-list segments,
// which tests don't simulate.
/* c8 ignore start */
if (nameMatch) {
out.push(nameMatch[1]!)
}
/* c8 ignore stop */
}
}
return out
}
/**
* Pull every `export const Foo` / `export function Foo` / `export { Foo }` from
* a TS file. Also matches `.d.ts` declaration forms (`export declare const
* Foo`, `export declare function Foo`) since the fallback path reads
* `primordials.d.ts` from `node_modules` when no sibling clone is present.
*/
export function extractTsExports(src: string): string[] {
const out = new Set<string>()
for (const m of src.matchAll(
/^export\s+(?:declare\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm,
)) {
out.add(m[1]!)
}
for (const m of src.matchAll(
/^export\s+(?:declare\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm,
)) {
out.add(m[1]!)
}
for (const m of src.matchAll(/^export\s*\{\s*([^}]+)\}/gm)) {
for (const raw of m[1]!.split(',')) {
const trimmed = raw.trim()
if (!trimmed) {
continue
}
const nameMatch = NAME_HEAD_RE.exec(trimmed)
// nameMatch null arm fires on malformed export-list segments,
// which tests don't simulate.
/* c8 ignore start */
if (nameMatch) {
out.add(nameMatch[1]!)
}
/* c8 ignore stop */
}
}
return [...out]
}
/**
* Read TS exports from a resolved primordials path. Handles both the legacy
* single-file layout (returns one file's exports) and the post-split directory
* layout (concatenates exports across every `*.ts` / `*.d.ts` leaf).
*/
export function readSocketLibPrimordialNames(resolved: string): Set<string> {
const stat = statSync(resolved)
if (stat.isFile()) {
return new Set(extractTsExports(readFileSync(resolved, 'utf8')))
}
// Directory: concatenate all *.ts and *.d.ts leaves.
const out = new Set<string>()
// Each scanned leaf either has TS exports or is a leftover declaration
// file; we don't separate them — the parser handles both forms.
/* c8 ignore start */
for (const name of readdirSync(resolved)) {
if (!name.endsWith('.ts') && !name.endsWith('.d.ts')) {
continue
}
const full = path.join(resolved, name)
const fileStat = statSync(full)
if (!fileStat.isFile()) {
continue
}
for (const exp of extractTsExports(readFileSync(full, 'utf8'))) {
out.add(exp)
}
}
/* c8 ignore stop */
return out
}
/**
* Locate socket-lib's primordials source. Search order:
*
* 1. `config.socketLibPrimordialsPath` if explicitly set. Accepts either a single
* file (legacy `primordials.ts` / `.d.ts`) or a directory of leaves
* (`primordials/`).
* 2. Sibling clone — `<repoRoot>/../socket-lib/src/primordials/` (post-split
* layout) or `<repoRoot>/../socket-lib/src/primordials.ts` (legacy
* single-file layout). Preferred for the dev-loop case where a developer is
* editing socket-lib and a consumer in parallel.
* 3. Installed copy — `<repoRoot>/node_modules/@socketsecurity/lib/
* dist/primordials/` (post-split) or `<repoRoot>/node_modules/
* @socketsecurity/lib/dist/primordials.d.ts` (legacy). The CI fallback.
*
* Throws when none of the candidates exist.
*/
export function resolveSocketLibPrimordials(
config: PrimordialsCheckConfig,
): string {
// Each resolver branch (explicit path, sibling clone, installed
// fallback) needs a specific test setup; the branch tracker reports
// them sub-arms separately even when the primary path is hit.
/* c8 ignore start */
if (config.socketLibPrimordialsPath) {
if (!existsSync(config.socketLibPrimordialsPath)) {
throw new ErrorCtor(
`socketLibPrimordialsPath does not exist: ${config.socketLibPrimordialsPath}`,
)
}
return config.socketLibPrimordialsPath
}
const repoRoot = config.repoRoot ?? process.cwd()
const siblingDir = path.resolve(
repoRoot,
'..',
'socket-lib',
'src',
'primordials',
)
if (existsSync(siblingDir)) {
return siblingDir
}
const siblingLegacy = path.resolve(
repoRoot,
'..',
'socket-lib',
'src',
'primordials.ts',
)
if (existsSync(siblingLegacy)) {
return siblingLegacy
}
const installedDir = path.resolve(
repoRoot,
'node_modules',
'@socketsecurity',
'lib',
'dist',
'primordials',
)
if (existsSync(installedDir)) {
return installedDir
}
const installedLegacy = path.resolve(
repoRoot,
'node_modules',
'@socketsecurity',
'lib',
'dist',
'primordials.d.ts',
)
if (existsSync(installedLegacy)) {
return installedLegacy
}
/* c8 ignore stop */
throw new ErrorCtor(
'Cannot locate socket-lib primordials source. ' +
`Looked at:\n ${siblingDir}\n ${siblingLegacy}\n ${installedDir}\n ${installedLegacy}\n` +
'Either clone socket-lib at ../socket-lib or run `pnpm install`.',
)
}
/**
* Strip `/* … */` block comments and `//` line comments. Comments inside
* primordials destructures would otherwise leak captured names; stripping first
* keeps the regex simple.
*/
export function stripComments(src: string): string {
let out = src.replace(/\/\*[\s\S]*?\*\//g, '')
out = out.replace(/^[\t ]*\/\/.*$/gm, '')
out = out.replace(/[\t ]+\/\/.*$/gm, '')
return out
}