@weft-js/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
@@ -0,0 +1,172 @@
1
+ // Phase 2's X-codes in `weft check` (research 11 §8.5, research 26 §7): responsive type and zoom (X005, X006, X008),
2
+ // the font build (X020–X023), media inside query boundaries (X030, X034), and refetches and streams (X033, X035,
3
+ // research 31 §7). X031, X032 and X036 are reported at runtime in dev. Reads .css and .html files and the JSX of the checked files; X022 also reads the font build's
4
+ // report in `.weft/fonts.json`.
5
+
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { basename, join } from 'node:path'
8
+ import { diagnostic, positions } from './diag.mjs'
9
+ import { discover } from './files.mjs'
10
+ import { scanJsx } from './jsx-scan.mjs'
11
+
12
+ const FLUID = /(-?[\d.]+)(vw|vi|cqi|cqw|vmin|vmax)\b/
13
+ const MAX_RATIO = 2.5
14
+
15
+ /** Comments blanked out, so offsets stay those of the file. */
16
+ const stripComments = css => css.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
17
+
18
+ /** `clamp(rem, rem + cqi, rem)` for a viewport/container-only size, from its px at 375 and 1280 (ratio ≤ 2.5). */
19
+ export function fluidFor(n) {
20
+ const at = w => (n * w) / 100
21
+ const min = at(375)
22
+ const max = Math.min(at(1280), min * MAX_RATIO)
23
+ const slope = (max - min) / (1280 - 375)
24
+ const r = x => Math.round(x * 1000) / 1000
25
+ return `clamp(${r(min / 16)}rem, calc(${r((min - slope * 375) / 16)}rem + ${r(slope * 100)}cqi), ${r(max / 16)}rem)`
26
+ }
27
+
28
+ /** X005/X006 for one `font-size` value; `at` is its offset in the file. */
29
+ function fontSize(value, at, report) {
30
+ if (/var\(/.test(value)) return // tokens: checked where they're defined
31
+ const fluid = FLUID.exec(value)
32
+ if (fluid && !/rem\b/.test(value)) {
33
+ const only = new RegExp(`^\\s*${fluid[0].replace(/[.]/g, '\\.')}\\s*$`).test(value)
34
+ report('X005', at, at + value.length, `\`font-size: ${value.trim()}\` scales with the ${fluid[2].startsWith('c') ? 'container' : 'viewport'} only: browser zoom and the user's font size don't change it (WCAG 1.4.4).`,
35
+ only ? { description: 'Use a fluid clamp with rem ends', safe: false, text: fluidFor(Number(fluid[1])) } : undefined)
36
+ }
37
+ const clamp = /^\s*clamp\(\s*([\d.]+)(rem|px)\s*,[\s\S]*,\s*([\d.]+)(rem|px)\s*\)\s*$/.exec(value)
38
+ if (clamp && clamp[2] === clamp[4] && Number(clamp[1]) > 0) {
39
+ const ratio = Number(clamp[3]) / Number(clamp[1])
40
+ if (ratio > MAX_RATIO) report('X006', at, at + value.length, `This fluid size grows ${ratio.toFixed(2)}× from its minimum to its maximum; above 2.5×, 200% zoom no longer doubles the text (WCAG 1.4.4).`)
41
+ }
42
+ }
43
+
44
+ const faceFamily = body => /font-family\s*:\s*(['"]?)([^;'"]+)\1/.exec(body)?.[2].trim()
45
+
46
+ /** Every `@font-face` in a CSS text: family, body and offsets. */
47
+ function faces(css) {
48
+ const out = []
49
+ for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) out.push({ family: faceFamily(m[1]), body: m[1], start: m.index, bodyStart: m.index + m[0].indexOf('{') + 1, end: m.index + m[0].length })
50
+ return out
51
+ }
52
+
53
+ /**
54
+ * @param {string} root
55
+ * @param {{ files: { rel: string, text: string }[], exclude?: string[], fontBuild?: boolean }} options files: the checked .tsx/.jsx files
56
+ */
57
+ export function textChecks(root, { files, exclude = [], fontBuild = false }) {
58
+ const out = []
59
+ const sheets = discover(root, { include: ['**/*.css'], exclude }).map(rel => ({ rel, text: readFileSync(join(root, rel), 'utf8') }))
60
+ const pages = discover(root, { include: ['**/*.html'], exclude }).map(rel => ({ rel, text: readFileSync(join(root, rel), 'utf8') }))
61
+ const fontsJson = join(root, '.weft', 'fonts.json')
62
+ const built = existsSync(fontsJson) ? JSON.parse(readFileSync(fontsJson, 'utf8')) : null
63
+
64
+ const reporter = file => {
65
+ const pos = positions(file.text)
66
+ /** `fix.at`: the replaced range when it isn't the flagged one. */
67
+ return (code, start, end, why, fix) => {
68
+ const [a, b] = fix?.at ?? [start, end]
69
+ out.push(diagnostic({ code, file: file.rel, span: pos.span(start, end), offset: [start, end], why, fix: fix && { description: fix.description, safe: fix.safe, edits: [{ file: file.rel, span: pos.span(a, b), offset: [a, b], text: fix.text }] } }))
70
+ }
71
+ }
72
+
73
+ // -- CSS: font sizes and font faces --
74
+ const allFaces = sheets.flatMap(s => faces(stripComments(s.text)).map(f => ({ ...f, sheet: s })))
75
+ const families = new Set(allFaces.map(f => f.family).filter(Boolean))
76
+ const faceUrls = new Set(allFaces.flatMap(f => [...f.body.matchAll(/url\(\s*['"]?([^'")]+)/g)].map(m => basename(m[1].split('?')[0]))))
77
+ for (const s of sheets) {
78
+ const css = stripComments(s.text)
79
+ const report = reporter(s)
80
+ for (const m of css.matchAll(/(?<![\w-])font-size\s*:\s*([^;}]+)/g)) fontSize(m[1], m.index + m[0].indexOf(m[1], m[0].indexOf(':')), report)
81
+ for (const f of faces(css)) {
82
+ const display = /font-display\s*:\s*(block|auto)\b/.exec(f.body)
83
+ if (display) {
84
+ const at = f.bodyStart + display.index + display[0].lastIndexOf(display[1])
85
+ report('X021', at, at + display[1].length, `\`font-display: ${display[1]}\` hides text for up to 3 s while the font loads; with a matched fallback face, \`swap\` shifts nothing.`, { description: 'Use swap', safe: true, text: 'swap' })
86
+ }
87
+ const local = /url\(\s*['"]?(?!https?:|data:)/.test(f.body)
88
+ if (!local || !f.family || f.family.endsWith('Fallback')) continue
89
+ if (!families.has(`${f.family} Fallback`)) {
90
+ const how = fontBuild ? 'declare it in `text({ fonts })`, which generates the fallback' : 'add `@weft-js/text` and declare it in `text({ fonts })`'
91
+ report('X020', f.start, f.start + '@font-face'.length, `\`@font-face\` for "${f.family}" has no "${f.family} Fallback" face: the swap from the system font shifts layout. Fix: ${how}.`)
92
+ }
93
+ if (!/unicode-range\s*:/.test(f.body)) report('X022', f.start, f.start + '@font-face'.length, `"${f.family}" ships the whole font file: no \`unicode-range\`, so it isn't subset.`)
94
+ }
95
+ }
96
+ for (const [code, why] of built?.report?.overBudget ?? []) {
97
+ if (code === 'X022') out.push(diagnostic({ code: 'X022', file: '.weft/fonts.json', why }))
98
+ }
99
+
100
+ // -- Markup: viewport meta, font preloads, media in boundaries, pending timers --
101
+ const viewport = (file, start, end, content, valueStart) => {
102
+ const parts = content.split(',').map(s => s.trim()).filter(Boolean)
103
+ const bad = parts.filter(p => /^user-scalable\s*=\s*(no|0)$/i.test(p) || (/^maximum-scale\s*=/i.test(p) && parseFloat(p.split('=')[1]) < 2))
104
+ if (!bad.length) return
105
+ const keep = parts.filter(p => !bad.includes(p)).join(', ')
106
+ reporter(file)('X008', start, end, `The viewport meta blocks zoom (\`${bad.join('`, `')}\`): people who need larger text can't pinch to get it (WCAG 1.4.4).`,
107
+ { description: 'Remove the zoom limits', safe: true, text: keep, at: [valueStart, valueStart + content.length] })
108
+ }
109
+ for (const p of pages) {
110
+ for (const m of p.text.matchAll(/<meta\b[^>]*name\s*=\s*["']viewport["'][^>]*>/gi)) {
111
+ const c = /content\s*=\s*(["'])([^"']*)\1/i.exec(m[0])
112
+ if (c) viewport(p, m.index, m.index + m[0].length, c[2], m.index + c.index + c[0].indexOf(c[2]))
113
+ }
114
+ for (const m of p.text.matchAll(/<link\b[^>]*rel\s*=\s*["']preload["'][^>]*>/gi)) {
115
+ if (/\bas\s*=\s*["']font["']/i.test(m[0])) preload(p, /href\s*=\s*["']([^"']+)/i.exec(m[0])?.[1], m.index, m.index + m[0].length)
116
+ }
117
+ }
118
+
119
+ /** X023: a font preload no `@font-face` uses (the font build's own preloads live under its asset URLs). */
120
+ function preload(file, href, start, end) {
121
+ if (href && !faceUrls.has(basename(href.split('?')[0])) && !href.includes('@weft-assets') && !href.startsWith('/assets/')) {
122
+ reporter(file)('X023', start, end, `This preloads \`${href}\`, which no \`@font-face\` uses: the bytes compete with the page's own critical requests.`)
123
+ }
124
+ }
125
+
126
+ for (const f of files) {
127
+ if (!/\.[jt]sx$/.test(f.rel)) continue
128
+ let scan
129
+ try {
130
+ scan = scanJsx(f.text, f.rel)
131
+ } catch {
132
+ continue // syntax errors are W000's
133
+ }
134
+ const report = reporter(f)
135
+ // X033: `x.pending ? <Skeleton /> : …`, with no `x.value` test beside it.
136
+ for (const m of f.text.matchAll(/\b([\w$]+)\.pending\s*(?:\?|&&)\s*\(?\s*<\s*(Shape|Skeleton|Spinner|Progress)\b/g)) {
137
+ const line = f.text.slice(f.text.lastIndexOf('\n', m.index) + 1, m.index)
138
+ if (!line.includes(`${m[1]}.value`)) report('X033', m.index, m.index + m[0].length, `\`${m[1]}.pending\` is true on every refetch too, so this <${m[2]}> flashes over a value that's already on screen.`)
139
+ }
140
+ for (const el of scan.elements) {
141
+ const style = el.attr('style')
142
+ if (style) {
143
+ if (style.kind === 'string') for (const m of style.value.matchAll(/font-size\s*:\s*([^;]+)/g)) fontSize(m[1], style.valueStart + 1 + m.index + m[0].indexOf(m[1]), report)
144
+ else for (const m of (style.expr ?? '').matchAll(/fontSize\s*:\s*(['"`])([^'"`]+)\1/g)) fontSize(m[2], style.valueStart + m.index + m[0].indexOf(m[2]), report)
145
+ }
146
+ if (el.name === 'meta' && el.attr('name')?.value === 'viewport' && el.attr('content')?.kind === 'string') {
147
+ const c = el.attr('content')
148
+ viewport(f, el.start, el.end, c.value, c.valueStart + 1)
149
+ }
150
+ if (el.name === 'link' && el.attr('rel')?.value === 'preload' && el.attr('as')?.value === 'font') {
151
+ preload(f, el.attr('href')?.value, el.start, el.end)
152
+ }
153
+ if (['img', 'video', 'iframe'].includes(el.name) && el.ancestors.includes('Boundary')) {
154
+ const sized = (el.attr('width') && el.attr('height')) || /aspect-?ratio/i.test(style?.value ?? style?.expr ?? '')
155
+ if (!sized) report('X030', el.start, el.tagEnd, `<${el.name}> inside a <Boundary> has no \`width\`/\`height\` or \`aspect-ratio\`: it renders 0 px tall, then pushes the content down when it loads.`)
156
+ }
157
+ if (/overflow(-[xy]|Y|X)?\s*:\s*['"]?(auto|scroll)/.test(style?.value ?? style?.expr ?? '')) {
158
+ const live = /\{\s*(\w+)\.value\b/.exec(el.children)?.[1]
159
+ if (live && new RegExp(`\\b${live}\\s*=\\s*[^\\n]*\\bstream\\b`).test(f.text)) report('X035', el.start, el.tagEnd, `\`${live}\` is streamed straight into a scroll container: as its tail reflows, the lines above it move.`)
160
+ }
161
+ const pending = el.name === 'Boundary' && el.attr('pending')
162
+ if (pending?.kind === 'expr') {
163
+ const tag = /^<\s*([A-Z][\w.]*)/.exec(pending.expr)?.[1]
164
+ const timers = /\b(setTimeout|setInterval)\s*\(/
165
+ if (timers.test(pending.expr) || (tag && timers.test(scan.functions.get(tag) ?? ''))) {
166
+ report('X034', pending.start, pending.end, `This \`pending\` fallback${tag ? ` (<${tag}>)` : ''} runs its own timers: <Boundary> already waits 150 ms before showing it and holds it 400 ms, and a second clock makes it flash.`)
167
+ }
168
+ }
169
+ }
170
+ }
171
+ return out
172
+ }
package/src/tokens.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // `weft tokens`: warp's resolved tokens as DTCG 2025.10 (PLAN §6, research 37), for Figma, Style Dictionary and Terrazzo.
2
+ // warp owns the values, so this asks the app's warp, with the app's seed and overrides, and writes what it returns.
3
+
4
+ import { mkdirSync, writeFileSync } from 'node:fs'
5
+ import { createRequire } from 'node:module'
6
+ import { join, resolve } from 'node:path'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { loadConfig } from './load-config.mjs'
9
+
10
+ export async function writeTokens(root) {
11
+ root = resolve(root)
12
+ let plugin
13
+ try {
14
+ plugin = await import(pathToFileURL(createRequire(join(root, 'package.json')).resolve('@weft-js/warp/plugin')).href)
15
+ } catch {
16
+ throw Object.assign(new Error('`weft tokens` exports warp\'s tokens, and this app has no @weft-js/warp'), { code: 'E_NO_WARP' })
17
+ }
18
+ const css = (await loadConfig(root)).contributions.find(c => c.name === '@weft-js/warp')?.manifest.tokens?.css ?? ''
19
+ const dir = join(root, '.weft', 'tokens')
20
+ mkdirSync(dir, { recursive: true })
21
+ const files = plugin.dtcg(css)
22
+ for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), `${JSON.stringify(content, null, 2)}\n`)
23
+ return { ok: true, dir: '.weft/tokens', files: Object.keys(files) }
24
+ }
package/src/trust.mjs ADDED
@@ -0,0 +1,282 @@
1
+ // Plugin trust (PLAN §7c, research 13 §2.9): `.weft/plugins.lock.json` records what each plugin may do and hashes its
2
+ // code, so a change is visible and blocked until `weft plugins approve`. It is a diff, not a sandbox: `contribute()` has
3
+ // already run by the time any of this looks at a plugin, and the audit reads source, which code can hide from.
4
+
5
+ import { createHash } from 'node:crypto'
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
7
+ import { builtinModules } from 'node:module'
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
9
+ import ts from 'typescript'
10
+ import { diagnostic, locate } from './diag.mjs'
11
+
12
+ export const lockFile = '.weft/plugins.lock.json'
13
+
14
+ const refused = message => Object.assign(new Error(message), { code: 'E_TRUST' })
15
+ const posix = p => p.split(sep).join('/')
16
+ /** @param {string[]} list */
17
+ const sorted = list => [...new Set(list)].sort()
18
+
19
+ /** Permissions as flat strings, one per grant, so a lock diff reads line by line: `fs.read:fonts/**`, `native`. */
20
+ export function grants(p = {}) {
21
+ const each = (kind, list) => (list ?? []).map(v => `${kind}:${v}`)
22
+ return sorted([...each('fs.read', p.fs?.read), ...each('fs.write', p.fs?.write), ...each('net', p.net), ...each('env', p.env), ...each('spawn', p.spawn), ...(p.native ? ['native'] : [])])
23
+ }
24
+
25
+ /** The lockfile, or `broken` when it can't be read: an unresolved merge shouldn't read as "nothing approved". */
26
+ function readLock(root) {
27
+ const file = join(root, lockFile)
28
+ if (!existsSync(file)) return { found: false, plugins: {} }
29
+ try {
30
+ const data = JSON.parse(readFileSync(file, 'utf8'))
31
+ if (data?.version !== 1 || typeof data.plugins !== 'object' || data.plugins === null) return { found: true, plugins: {}, broken: 'it is not a version 1 lockfile' }
32
+ return { found: true, plugins: data.plugins }
33
+ } catch (error) {
34
+ return { found: true, plugins: {}, broken: String(error.message).split('\n')[0] }
35
+ }
36
+ }
37
+
38
+ /** The package a plugin name is in: `@weft-js/db/mongo` is a subpath of `@weft-js/db`. */
39
+ export const packageOf = name => name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/')
40
+
41
+ /** Where a plugin is installed: the nearest `node_modules/<package>`, through its symlink. Null for one defined in the config. */
42
+ function packageDir(root, name) {
43
+ for (let dir = root; ; dir = dirname(dir)) {
44
+ const at = join(dir, 'node_modules', packageOf(name))
45
+ if (existsSync(join(at, 'package.json'))) return realpathSync(at)
46
+ if (dirname(dir) === dir) return null
47
+ }
48
+ }
49
+
50
+ /** A hash of the package's files, the same from any installer. Its own dependencies are the package manager's lock's job. */
51
+ function hashPackage(dir) {
52
+ const hash = createHash('sha512')
53
+ const files = readdirSync(dir, { recursive: true, withFileTypes: true })
54
+ .filter(f => f.isFile() && !relative(dir, f.parentPath).split(sep).includes('node_modules'))
55
+ .map(f => join(f.parentPath, f.name))
56
+ .sort()
57
+ for (const file of files) {
58
+ const bytes = readFileSync(file)
59
+ hash.update(`${posix(relative(dir, file))}\0${bytes.length}\0`).update(bytes)
60
+ }
61
+ return `sha512-${hash.digest('base64')}`
62
+ }
63
+
64
+ const builtins = new Set(builtinModules)
65
+ const NEEDS = { fs: 'fs', 'fs/promises': 'fs', child_process: 'spawn', cluster: 'spawn', net: 'net', tls: 'net', dgram: 'net', dns: 'net', 'dns/promises': 'net', http: 'net', https: 'net', http2: 'net' }
66
+ const NET_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource'])
67
+ /** Bundlers inline it, and it says nothing about the machine. */
68
+ const AMBIENT_ENV = new Set(['NODE_ENV'])
69
+
70
+ /** What one file does directly, and what it imports. */
71
+ function scanFile(text, file) {
72
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX)
73
+ const uses = []
74
+ const imports = []
75
+ const use = (need, node) => uses.push({ need, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 })
76
+ const specifier = (name, node) => {
77
+ const bare = name.replace(/^node:/, '')
78
+ if (name.endsWith('.node')) use('native', node)
79
+ else if (builtins.has(bare)) NEEDS[bare] && use(NEEDS[bare], node)
80
+ else imports.push(name)
81
+ }
82
+ const isProcess = node => ts.isIdentifier(node) && node.text === 'process'
83
+ const visit = node => {
84
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
85
+ if (!(node.importClause?.isTypeOnly || node.isTypeOnly)) specifier(node.moduleSpecifier.text, node)
86
+ } else if (ts.isCallExpression(node)) {
87
+ const callee = node.expression
88
+ const [first] = node.arguments
89
+ if ((callee.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(callee) && callee.text === 'require')) && first && ts.isStringLiteralLike(first)) specifier(first.text, node)
90
+ else if (ts.isIdentifier(callee) && NET_GLOBALS.has(callee.text)) use('net', node)
91
+ else if (ts.isPropertyAccessExpression(callee) && isProcess(callee.expression) && callee.name.text === 'dlopen') use('native', node)
92
+ } else if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && NET_GLOBALS.has(node.expression.text)) use('net', node)
93
+ else if (ts.isPropertyAccessExpression(node) && isProcess(node.expression) && node.name.text === 'env') {
94
+ const parent = node.parent
95
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) AMBIENT_ENV.has(parent.name.text) || use(`env:${parent.name.text}`, node)
96
+ else if (ts.isElementAccessExpression(parent) && parent.expression === node && ts.isStringLiteralLike(parent.argumentExpression)) AMBIENT_ENV.has(parent.argumentExpression.text) || use(`env:${parent.argumentExpression.text}`, node)
97
+ else use('env:*', node)
98
+ }
99
+ ts.forEachChild(node, visit)
100
+ }
101
+ visit(sf)
102
+ return { uses, imports }
103
+ }
104
+
105
+ const EXTENSIONS = ['', '.mjs', '.js', '.cjs', '.ts', '.tsx', '.mts', '/index.mjs', '/index.js', '/index.ts']
106
+ const sourceFile = /\.(?:[cm]?[jt]s|[jt]sx)$/
107
+ const onDisk = path => EXTENSIONS.map(e => path + e).find(p => sourceFile.test(p) && existsSync(p)) ?? null
108
+
109
+ /** A package export's file for this runtime. */
110
+ function exportTarget(value) {
111
+ if (typeof value === 'string') return value
112
+ if (!value || typeof value !== 'object') return null
113
+ return exportTarget(value.node ?? value.import ?? value.default ?? value.require)
114
+ }
115
+
116
+ function entryFile(dir, pkg, subpath) {
117
+ const map = pkg.exports
118
+ const keyed = map && typeof map === 'object' && Object.keys(map).some(k => k.startsWith('.'))
119
+ const target = exportTarget(keyed ? map[subpath] : subpath === '.' ? (map ?? pkg.main ?? 'index.js') : null)
120
+ return (target && onDisk(join(dir, target))) ?? (subpath === '.' ? null : onDisk(join(dir, subpath)))
121
+ }
122
+
123
+ /**
124
+ * The plugin's entry, and each module it names for the build or the server (a browser has no `fs` to reach). Only the
125
+ * package's own files are read; `dependencies` says what it stopped at.
126
+ */
127
+ export function audit(root, dir, contribution) {
128
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
129
+ // A package can hold several plugins, `<package>/<sub>/plugin`, and each answers for its own files.
130
+ const sub = contribution.name?.startsWith(`${pkg.name}/`) ? contribution.name.slice(pkg.name.length) : ''
131
+ const roots = [entryFile(dir, pkg, `.${sub}/plugin`) ?? entryFile(dir, pkg, `.${sub}`)]
132
+ for (const ref of contribution.manifest.provides ?? []) {
133
+ for (const [side, { module }] of Object.entries(ref.sides ?? {})) {
134
+ if (side === 'client') continue
135
+ if (module === pkg.name || module.startsWith(`${pkg.name}/`)) roots.push(entryFile(dir, pkg, `.${module.slice(pkg.name.length)}`))
136
+ else if (isAbsolute(module) || module.startsWith('.')) roots.push(onDisk(isAbsolute(module) ? module : resolve(root, module)))
137
+ }
138
+ }
139
+ const seen = new Set()
140
+ const queue = roots.filter(f => f && !relative(dir, f).startsWith('..'))
141
+ const uses = []
142
+ /** @type {Set<string>} */
143
+ const dependencies = new Set()
144
+ while (queue.length) {
145
+ const file = queue.pop()
146
+ if (seen.has(file)) continue
147
+ seen.add(file)
148
+ const found = scanFile(readFileSync(file, 'utf8'), file)
149
+ for (const u of found.uses) uses.push({ ...u, file: posix(relative(dir, file)) })
150
+ for (const name of found.imports) {
151
+ if (!name.startsWith('.')) {
152
+ if (name !== pkg.name && !name.startsWith(`${pkg.name}/`)) dependencies.add(name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/'))
153
+ continue
154
+ }
155
+ const next = onDisk(resolve(dirname(file), name))
156
+ if (next && !relative(dir, next).startsWith('..')) queue.push(next)
157
+ }
158
+ }
159
+ const granted = grants(contribution.permissions)
160
+ return {
161
+ uses: uses.sort((a, b) => a.need.localeCompare(b.need) || a.file.localeCompare(b.file) || a.line - b.line),
162
+ undeclared: sorted(uses.map(u => u.need).filter(need => !declared(need, granted))),
163
+ dependencies: [...dependencies].sort(),
164
+ }
165
+ }
166
+
167
+ /** Is `need` from the audit covered by what the plugin declared? */
168
+ function declared(need, granted) {
169
+ if (need === 'env:*') return granted.some(g => g.startsWith('env:'))
170
+ if (need.startsWith('env:')) return granted.includes(need) || granted.includes('env:*')
171
+ if (need === 'native') return granted.includes('native')
172
+ const kind = { fs: 'fs.', net: 'net:', spawn: 'spawn:' }[need]
173
+ return granted.some(g => g.startsWith(kind))
174
+ }
175
+
176
+ /**
177
+ * Every plugin against the lock. A plugin whose code is pinned and unchanged has nothing new to find, so only `full`
178
+ * reads it again; one with nothing to pin (a workspace package) is read every time.
179
+ * @param {{ full?: boolean }} [options]
180
+ */
181
+ export function trustStatus(root, loaded, { full = false } = {}) {
182
+ const lock = readLock(root)
183
+ const plugins = loaded.contributions.map(c => {
184
+ const entry = lock.plugins[c.name] ?? null
185
+ const dir = packageDir(root, c.name)
186
+ const source = !dir ? 'config' : dir.split(sep).includes('node_modules') ? 'package' : 'workspace'
187
+ const integrity = source === 'package' ? hashPackage(dir) : null
188
+ const permissions = grants(c.permissions)
189
+ const provides = sorted((c.manifest.provides ?? []).map(p => (p.key ? `${p.slot.id}[${p.key}]` : p.slot.id)))
190
+ // A scaffold approves permissions before there's an install to hash (`integrity: null`), and that's filled in by `approve`.
191
+ const changed = !!entry && (entry.integrity ?? null) !== integrity && !(entry.integrity == null && source === 'package')
192
+ const scanned = dir && (full || !entry || !integrity || !entry.integrity || changed) ? audit(root, dir, c) : null
193
+ const uses = scanned?.uses ?? []
194
+ // Without a scan the lock's own record stands: those are the same files.
195
+ const undeclared = scanned?.undeclared ?? entry?.undeclared ?? []
196
+ const added = permissions.filter(g => !entry?.permissions?.includes(g))
197
+ const removed = (entry?.permissions ?? []).filter(g => !permissions.includes(g))
198
+ const hidden = undeclared.filter(need => !entry?.undeclared?.includes(need))
199
+ return {
200
+ name: c.name, version: c.version ?? null, source, integrity, permissions, provides, uses, undeclared, dependencies: scanned?.dependencies ?? [],
201
+ locked: !!entry, lockedVersion: entry?.version ?? null, added, removed, hidden, integrityChanged: changed, blocked: added.length > 0 || hidden.length > 0 || changed,
202
+ }
203
+ })
204
+ return { lock: lockFile, found: lock.found, broken: lock.broken, plugins, stale: Object.keys(lock.plugins).filter(name => !plugins.some(p => p.name === name)).sort() }
205
+ }
206
+
207
+ /** P109 and P110 for what the lock doesn't cover. `trust: 'warn'` in the config makes them warnings. */
208
+ export function trustDiagnostics(root, loaded, status = trustStatus(root, loaded)) {
209
+ const severity = loaded.config.trust === 'warn' ? 'warning' : 'error'
210
+ const rel = loaded.file ? posix(relative(root, loaded.file)) : 'weft.config.ts'
211
+ const text = loaded.file ? readFileSync(loaded.file, 'utf8') : ''
212
+ const out = []
213
+ const report = (code, name, why, help) => out.push(diagnostic({ code, severity, owner: name, file: rel, why, help, ...locate(text, name) }))
214
+ if (status.broken) out.push(diagnostic({ code: 'P110', severity, file: lockFile, why: `${lockFile} can't be read: ${status.broken}.`, help: 'Fix it, or delete it and approve each plugin again.' }))
215
+ for (const p of status.plugins) {
216
+ const approve = `Review it, then run \`weft plugins approve ${p.name}\`.`
217
+ if (p.added.length) report('P109', p.name, `\`${p.name}\` asks for permissions the lock doesn't give it: ${p.added.join(', ')}.`, approve)
218
+ if (p.hidden.length) report('P109', p.name, `\`${p.name}\` uses ${p.hidden.join(', ')} without declaring it (${p.uses.find(u => p.hidden.includes(u.need))?.file ?? 'see `weft plugins audit`'}).`, `Have it declared, or review it and run \`weft plugins approve ${p.name}\`.`)
219
+ if (p.integrityChanged) report('P110', p.name, `\`${p.name}\`${p.lockedVersion && p.lockedVersion !== p.version ? ` went from ${p.lockedVersion} to ${p.version} and its` : `'s installed`} code isn't what the lock recorded.`, `Reinstall the locked version, or review the update and run \`weft plugins approve ${p.name}\`.`)
220
+ }
221
+ return out
222
+ }
223
+
224
+ /** Stops `weft dev` and `weft build` before a blocked plugin's build hooks run. */
225
+ export function requireTrust(root, loaded) {
226
+ if (loaded.diagnostics.some(d => d.code === 'P007')) return
227
+ const blocked = trustDiagnostics(root, loaded).filter(d => d.severity === 'error')
228
+ if (blocked.length) throw refused(blocked.map(d => `${d.code} ${d.why} ${d.fix.text}`).join('\n'))
229
+ }
230
+
231
+ /** `weft plugins audit --json`. */
232
+ export function auditCommand(root, loaded) {
233
+ const status = trustStatus(root, loaded, { full: true })
234
+ const diagnostics = trustDiagnostics(root, loaded, status)
235
+ return { ok: !diagnostics.some(d => d.severity === 'error'), ...status, diagnostics: [...loaded.diagnostics, ...diagnostics] }
236
+ }
237
+
238
+ /** `.weft/` in a .gitignore hides the lock, and a lock nobody commits protects nobody. */
239
+ function unignoreLock(root) {
240
+ const file = join(root, '.gitignore')
241
+ if (!existsSync(file)) return false
242
+ const text = readFileSync(file, 'utf8')
243
+ const next = text.replace(/^\/?\.weft\/?$/m, `.weft/*\n!${lockFile}`)
244
+ if (next === text) return false
245
+ writeFileSync(file, next)
246
+ return true
247
+ }
248
+
249
+ /**
250
+ * `weft plugins approve`: names, or `all`. With neither it only shows what's waiting, so approving is always something
251
+ * said out loud, one plugin at a time or all at once.
252
+ */
253
+ export function approveCommand(root, loaded, { names = [], all = false } = {}) {
254
+ const failed = loaded.diagnostics.find(d => d.code === 'P007')
255
+ if (failed) throw refused(`The config doesn't load, so nothing can be approved: ${failed.why}`)
256
+ const status = trustStatus(root, loaded, { full: true })
257
+ if (status.broken) throw refused(`${lockFile} can't be read: ${status.broken}. Fix it, or delete it and approve each plugin again.`)
258
+ const pending = status.plugins.filter(p => p.blocked)
259
+ const unknown = names.filter(n => !status.plugins.some(p => p.name === n))
260
+ if (unknown.length) throw Object.assign(new Error(`not in \`plugins\`: ${unknown.join(', ')}`), { code: 'E_USAGE' })
261
+ if (!all && !names.length) return { ok: !pending.length, written: false, lock: lockFile, approved: [], pending, stale: status.stale }
262
+
263
+ const chosen = status.plugins.filter(p => all || names.includes(p.name))
264
+ const entries = { ...readLock(root).plugins }
265
+ if (all) for (const name of status.stale) delete entries[name]
266
+ for (const p of chosen) entries[p.name] = { version: p.version, integrity: p.integrity, permissions: p.permissions, provides: p.provides, undeclared: p.undeclared }
267
+ const ordered = Object.fromEntries(Object.keys(entries).sort().map(name => [name, entries[name]]))
268
+ mkdirSync(join(root, '.weft'), { recursive: true })
269
+ writeFileSync(join(root, lockFile), `${JSON.stringify({ version: 1, plugins: ordered }, null, 2)}\n`)
270
+ return { ok: true, written: true, lock: lockFile, approved: chosen, pending: pending.filter(p => !chosen.includes(p)), stale: all ? [] : status.stale, gitignore: unignoreLock(root) }
271
+ }
272
+
273
+ /** One line per plugin for the terminal. */
274
+ export function formatTrust(p) {
275
+ const parts = [
276
+ ...p.added.map(g => `+${g}`),
277
+ ...p.removed.map(g => `-${g}`),
278
+ ...p.hidden.map(n => `undeclared ${n}`),
279
+ ...(p.integrityChanged ? [`code changed${p.lockedVersion && p.lockedVersion !== p.version ? ` (${p.lockedVersion} → ${p.version})` : ''}`] : []),
280
+ ]
281
+ return `${p.blocked ? '!' : ' '} ${p.name}@${p.version ?? '?'} ${parts.join(' ') || (p.locked ? 'approved' : 'not locked, asks for nothing')}`
282
+ }
package/src/weft.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // The installed weft: its compiler (only through the public `@weft-js/core/compiler` transform()), version and exports.
2
+
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+ import { dirname, join } from 'node:path'
6
+ import ts from 'typescript'
7
+
8
+ const own = createRequire(import.meta.url)
9
+
10
+ /** The project's own weft wins; the CLI's weft (its dev/peer dependency) is the fallback. */
11
+ export function resolveWeft(root) {
12
+ const tries = [() => createRequire(join(root, 'package.json')).resolve('@weft-js/core/compiler'), () => own.resolve('@weft-js/core/compiler')]
13
+ for (const [i, attempt] of tries.entries()) {
14
+ let path
15
+ try {
16
+ path = attempt()
17
+ } catch {
18
+ continue
19
+ }
20
+ let dir = dirname(path)
21
+ while (!existsSync(join(dir, 'package.json')) && dirname(dir) !== dir) dir = dirname(dir)
22
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
23
+ return { from: i === 0 ? 'project' : 'cli', dir, version: pkg.version, pkg, compilerPath: path }
24
+ }
25
+ throw Object.assign(new Error('weft is not installed: `@weft-js/core/compiler` resolves neither from the project nor from @weft-js/cli'), { code: 'E_NO_WEFT' })
26
+ }
27
+
28
+ const compilers = new Map()
29
+
30
+ /** @returns {{ transform(source: string, options?: object): { code: string | null, diagnostics: any[] } }} */
31
+ export function loadCompiler(weft) {
32
+ if (!compilers.has(weft.compilerPath)) compilers.set(weft.compilerPath, own(weft.compilerPath))
33
+ return compilers.get(weft.compilerPath)
34
+ }
35
+
36
+ /** Exported names per public subpath, read from the installed package's entry files (no execution). */
37
+ export function exportsOf(weft, subpaths = ['.', './dom', './ssr', './test', './browser', './interaction', './style', './surface']) {
38
+ const out = {}
39
+ for (const sub of subpaths) {
40
+ const target = weft.pkg.exports?.[sub]
41
+ const file = typeof target === 'string' ? join(weft.dir, target) : null
42
+ if (!file || !/\.[mc]?[jt]sx?$/.test(file) || !existsSync(file)) continue
43
+ /** @type {Set<string>} */
44
+ const names = new Set()
45
+ const read = path => {
46
+ const sf = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true)
47
+ for (const node of sf.statements) {
48
+ const exported = ts.canHaveModifiers(node) && ts.getModifiers(node)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
49
+ if (ts.isExportDeclaration(node) && !node.exportClause && !node.isTypeOnly) {
50
+ // `export * from './index'`: the `weft` entry is a thin one over the runtime.
51
+ const next = join(dirname(path), `${node.moduleSpecifier.text}.ts`)
52
+ if (existsSync(next)) read(next)
53
+ } else if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
54
+ if (!node.isTypeOnly) for (const el of node.exportClause.elements) if (!el.isTypeOnly) names.add(el.name.text)
55
+ } else if (exported && (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
56
+ names.add(node.name.text)
57
+ } else if (exported && ts.isVariableStatement(node)) {
58
+ for (const d of node.declarationList.declarations) if (ts.isIdentifier(d.name)) names.add(d.name.text)
59
+ }
60
+ }
61
+ }
62
+ read(file)
63
+ out[sub === '.' ? '@weft-js/core' : `@weft-js/core/${sub.slice(2)}`] = [...names].sort()
64
+ }
65
+ return out
66
+ }
@@ -0,0 +1,105 @@
1
+ // Scratch copies of a project for the gate, and vitest runs in them. A copy lives in `<root>/.weft/gate/<id>`, so
2
+ // node_modules hoisted above the project still resolve; the project's own node_modules dirs are symlinked, not copied.
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync, copyFileSync } from 'node:fs'
6
+ import { createRequire } from 'node:module'
7
+ import { dirname, join, relative } from 'node:path'
8
+
9
+ const skipDirs = new Set(['.git', '.weft', 'dist', 'coverage', '.turbo', 'target'])
10
+
11
+ /**
12
+ * Copy `root` into a new scratch dir. `exclude(rel)` leaves a path out (relative to root, posix).
13
+ * @returns {string} the copy's real path
14
+ */
15
+ export function copyProject(root, { exclude = () => false, id = Math.random().toString(36).slice(2, 8) } = {}) {
16
+ const dest = join(root, '.weft', 'gate', `${process.pid}-${id}`)
17
+ rmSync(dest, { recursive: true, force: true })
18
+ mkdirSync(dest, { recursive: true })
19
+ const go = (from, to, rel) => {
20
+ for (const e of readdirSync(from, { withFileTypes: true })) {
21
+ const r = rel ? `${rel}/${e.name}` : e.name
22
+ const src = join(from, e.name)
23
+ if (e.name === 'node_modules') { symlinkSync(realpathSync(src), join(to, e.name)); continue }
24
+ if (exclude(r)) continue
25
+ if (e.isDirectory()) {
26
+ if (skipDirs.has(e.name)) continue
27
+ mkdirSync(join(to, e.name))
28
+ go(src, join(to, e.name), r)
29
+ } else if (e.isFile()) copyFileSync(src, join(to, e.name))
30
+ else if (e.isSymbolicLink()) { try { symlinkSync(realpathSync(src), join(to, e.name)) } catch {} }
31
+ }
32
+ }
33
+ go(root, dest, '')
34
+ return realpathSync(dest)
35
+ }
36
+
37
+ /** Write (or with `null`, delete) files in a copy. */
38
+ export function writeFiles(dir, files) {
39
+ for (const [rel, text] of Object.entries(files)) {
40
+ const abs = join(dir, rel)
41
+ if (text == null) rmSync(abs, { force: true })
42
+ else { mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, text) }
43
+ }
44
+ }
45
+
46
+ export function removeCopy(dir) {
47
+ rmSync(dir, { recursive: true, force: true })
48
+ // Leave `.weft/gate` behind only when something else is in it.
49
+ const gateDir = dirname(dir)
50
+ try { if (!readdirSync(gateDir).length) rmSync(gateDir, { recursive: true }) } catch {}
51
+ }
52
+
53
+ /** The vitest CLI the project would run: its own, else the CLI's. */
54
+ export function vitestBin(root) {
55
+ for (const req of [createRequire(join(root, 'package.json')), createRequire(import.meta.url)]) {
56
+ try {
57
+ let dir = dirname(req.resolve('vitest'))
58
+ while (!existsSync(join(dir, 'package.json')) || !/"name":\s*"vitest"/.test(readFileSync(join(dir, 'package.json'), 'utf8'))) {
59
+ if (dirname(dir) === dir) throw new Error('no vitest')
60
+ dir = dirname(dir)
61
+ }
62
+ return join(dir, 'vitest.mjs')
63
+ } catch {}
64
+ }
65
+ throw Object.assign(new Error('vitest is not installed in this project'), { code: 'E_NO_VITEST' })
66
+ }
67
+
68
+ /**
69
+ * Run vitest on `files` (relative to `dir`) and read its JSON report.
70
+ * @returns {Promise<{ tests: Map<string, { status: string, message?: string }>, loadErrors: Map<string, string>, timedOut: boolean, ms: number, stderr: string }>}
71
+ * `tests` is keyed `<file>::<suite > test>`; `loadErrors` by file, for files that failed before running a test.
72
+ */
73
+ export function runVitest(dir, files, { bin, timeoutMs = 120_000, config } = {}) {
74
+ const report = join(dir, `.weft-report-${Math.random().toString(36).slice(2, 8)}.json`)
75
+ const args = [bin, 'run', '--root', dir, '--reporter=json', `--outputFile=${report}`, '--passWithNoTests', ...(config ? ['--config', config] : []), ...files]
76
+ const started = performance.now()
77
+ return new Promise(resolve => {
78
+ const child = spawn(process.execPath, args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, FORCE_COLOR: '0', CI: '1' }, detached: true })
79
+ let stderr = ''
80
+ child.stderr.on('data', d => { if (stderr.length < 20_000) stderr += d })
81
+ let timedOut = false
82
+ const timer = setTimeout(() => { timedOut = true; try { if (child.pid !== undefined) process.kill(-child.pid, 'SIGKILL') } catch {} }, timeoutMs)
83
+ child.on('close', () => {
84
+ clearTimeout(timer)
85
+ const tests = new Map(), loadErrors = new Map()
86
+ if (!timedOut && existsSync(report)) {
87
+ let data
88
+ try { data = JSON.parse(readFileSync(report, 'utf8')) } catch {}
89
+ rmSync(report, { force: true })
90
+ for (const f of data?.testResults ?? []) {
91
+ const file = relative(dir, safeReal(f.name)).split('\\').join('/')
92
+ if (!f.assertionResults.length) { loadErrors.set(file, firstLine(f.message) || 'the file failed to load'); continue }
93
+ for (const a of f.assertionResults)
94
+ tests.set(`${file}::${[...a.ancestorTitles, a.title].join(' > ')}`, { status: a.status, message: firstLine(a.failureMessages?.[0]) })
95
+ }
96
+ }
97
+ resolve({ tests, loadErrors, timedOut, ms: Math.round(performance.now() - started), stderr })
98
+ })
99
+ })
100
+ }
101
+
102
+ const safeReal = p => { try { return realpathSync(p) } catch { return p } }
103
+ const firstLine = s => s?.split('\n').find(l => l.trim())?.trim().slice(0, 300)
104
+
105
+ export const isDir = p => { try { return lstatSync(p).isDirectory() } catch { return false } }