@etus/seven-skill 0.1.0-beta.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 (42) hide show
  1. package/.claude/skills/seven/SKILL.md +162 -0
  2. package/.claude/skills/seven/reference/audit.md +120 -0
  3. package/.claude/skills/seven/reference/brand.md +24 -0
  4. package/.claude/skills/seven/reference/clarify.md +76 -0
  5. package/.claude/skills/seven/reference/color-and-contrast.md +84 -0
  6. package/.claude/skills/seven/reference/motion-design.md +71 -0
  7. package/.claude/skills/seven/reference/polish.md +55 -0
  8. package/.claude/skills/seven/reference/product.md +45 -0
  9. package/.claude/skills/seven/reference/shape.md +85 -0
  10. package/.claude/skills/seven/reference/spatial-design.md +60 -0
  11. package/.claude/skills/seven/reference/typography.md +43 -0
  12. package/.claude/skills/seven/reference/ux-writing.md +47 -0
  13. package/.claude/skills/seven/scripts/load-context.mjs +84 -0
  14. package/.claude-plugin/marketplace.json +34 -0
  15. package/.claude-plugin/plugin.json +12 -0
  16. package/LICENSE +190 -0
  17. package/NOTICE.md +26 -0
  18. package/README.md +118 -0
  19. package/cli/bin/commands/skills.mjs +664 -0
  20. package/cli/bin/seven.mjs +68 -0
  21. package/cli/engine/browser/injected/index.mjs +84 -0
  22. package/cli/engine/cli/main.mjs +215 -0
  23. package/cli/engine/detect-antipatterns-browser.js +3014 -0
  24. package/cli/engine/detect-antipatterns.mjs +44 -0
  25. package/cli/engine/engines/browser/detect-url.mjs +108 -0
  26. package/cli/engine/engines/regex/detect-text.mjs +508 -0
  27. package/cli/engine/engines/static-html/css-cascade.mjs +957 -0
  28. package/cli/engine/engines/static-html/detect-html.mjs +211 -0
  29. package/cli/engine/engines/visual/screenshot-contrast.mjs +192 -0
  30. package/cli/engine/findings.mjs +28 -0
  31. package/cli/engine/node/file-system.mjs +212 -0
  32. package/cli/engine/profile/profiler.mjs +169 -0
  33. package/cli/engine/registry/seven-antipatterns.mjs +494 -0
  34. package/cli/engine/rules/checks.mjs +1518 -0
  35. package/cli/engine/shared/color.mjs +204 -0
  36. package/cli/engine/shared/constants.mjs +91 -0
  37. package/cli/engine/shared/page.mjs +9 -0
  38. package/cli/engine/shared/tokens.mjs +153 -0
  39. package/cli/engine/token-data.generated.mjs +691 -0
  40. package/docs/detector-rules.md +605 -0
  41. package/docs/figma-token-rule-exploration.md +185 -0
  42. package/package.json +76 -0
@@ -0,0 +1,211 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ import { isFullPage } from '../../shared/page.mjs'
9
+ import { finding } from '../../findings.mjs'
10
+ import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs'
11
+ import {
12
+ checkElementBorders,
13
+ checkElementColors,
14
+ checkElementGlow,
15
+ checkElementLucideIcon,
16
+ checkElementMotion,
17
+ checkElementPillOnButton,
18
+ checkElementQuality,
19
+ checkElementTactilePress,
20
+ checkHtmlPatterns,
21
+ checkPageLayout,
22
+ checkPageQualityFromDoc,
23
+ checkPageTypography,
24
+ resolveBackground,
25
+ } from '../../rules/checks.mjs'
26
+ import { detectText } from '../regex/detect-text.mjs'
27
+ import {
28
+ StaticDocument,
29
+ buildStaticStyleMap,
30
+ buildStaticWindow,
31
+ collectStaticCssText,
32
+ } from './css-cascade.mjs'
33
+
34
+ // ─── Static-HTML engine ─────────────────────────────────────────────────────
35
+ // HTML-file detector. Two layers:
36
+ // 1. The Seven text checks run over the raw markup (inline <style>, style=,
37
+ // class= attributes are all part of the file text).
38
+ // 2. The document is parsed with htmlparser2, its CSS cascade is resolved by
39
+ // css-cascade.mjs into a StaticDocument, and per-element + page-level
40
+ // checks run against the computed styles. This surfaces drift that lives
41
+ // only in the resolved cascade — a side-stripe border declared in a
42
+ // stylesheet rule, a low-contrast token pair, mixed radius scales.
43
+ // When the static parser is unavailable the text scan still runs as a fallback.
44
+
45
+ // Per-element rules: each names a selector and a check. `customPropMap` is
46
+ // reserved for var() resolution the cascade already performs upstream.
47
+ const STATIC_ELEMENT_RULES = [
48
+ {
49
+ id: 'side-stripe-border',
50
+ selector: '*',
51
+ run: (el, tag, style) => checkElementBorders(tag, style, null),
52
+ },
53
+ {
54
+ id: 'color-rules',
55
+ selector: '*',
56
+ run: (el, tag, style, window) => checkElementColors(el, style, tag, window),
57
+ },
58
+ {
59
+ id: 'shadows-not-borders',
60
+ selector: '*',
61
+ run: (el, tag, style, window) =>
62
+ checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window)),
63
+ },
64
+ {
65
+ id: 'motion-rules',
66
+ selector: '*',
67
+ run: (el, tag, style) => checkElementMotion(tag, style),
68
+ },
69
+ {
70
+ id: 'missing-tactile-press',
71
+ selector: 'button,a,[role]',
72
+ run: (el, tag, style) => checkElementTactilePress(el, style, tag),
73
+ },
74
+ {
75
+ id: 'pill-on-button',
76
+ selector: 'button,[role="button"]',
77
+ run: (el, tag, style) => checkElementPillOnButton(el, style, tag),
78
+ },
79
+ {
80
+ id: 'non-lucide-icon',
81
+ selector: 'svg',
82
+ run: (el, tag) => checkElementLucideIcon(el, tag),
83
+ },
84
+ {
85
+ id: 'quality-rules',
86
+ selector: '*',
87
+ run: (el, tag, style, window) => checkElementQuality(el, style, tag, window),
88
+ },
89
+ ]
90
+
91
+ /**
92
+ * @param {string} filePath path to an HTML file
93
+ * @param {{ ruleIds?: string[], profile?: object }} [options]
94
+ * @returns {Promise<Array>} normalized findings
95
+ */
96
+ async function detectHtml(filePath, options = {}) {
97
+ const profile = options?.profile
98
+ const ruleIds = options?.ruleIds || null
99
+
100
+ const html = profileStep(
101
+ profile,
102
+ { engine: 'static-html', phase: 'setup', ruleId: 'read-html', target: filePath },
103
+ () => fs.readFileSync(filePath, 'utf-8')
104
+ )
105
+
106
+ // Layer 1: the Seven text checks over the raw markup. Always runs — even on
107
+ // the static-parser success path — so inline style=/class= drift surfaces.
108
+ const findings = detectText(html, filePath, options)
109
+
110
+ let modules
111
+ try {
112
+ modules = await profileStepAsync(
113
+ profile,
114
+ { engine: 'static-html', phase: 'setup', ruleId: 'import-static-parser', target: filePath },
115
+ async () => {
116
+ const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
117
+ import('htmlparser2'),
118
+ import('css-select'),
119
+ import('css-tree'),
120
+ import('domutils'),
121
+ ])
122
+ return {
123
+ parseDocument: htmlparser2.parseDocument,
124
+ selectAll: cssSelect.selectAll,
125
+ selectOne: cssSelect.selectOne,
126
+ is: cssSelect.is,
127
+ csstree,
128
+ domutils,
129
+ }
130
+ }
131
+ )
132
+ } catch {
133
+ // Static parser unavailable — the text scan above is the result.
134
+ return findings
135
+ }
136
+
137
+ const resolvedPath = path.resolve(filePath)
138
+ const fileDir = path.dirname(resolvedPath)
139
+ let root
140
+ try {
141
+ root = profileStep(
142
+ profile,
143
+ { engine: 'static-html', phase: 'parse-html', ruleId: 'parse-document', target: filePath },
144
+ () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true })
145
+ )
146
+ } catch {
147
+ return findings
148
+ }
149
+
150
+ const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules)
151
+ const document = new StaticDocument(root, modules)
152
+ buildStaticStyleMap(root, document, cssText, modules, profile, filePath)
153
+ const window = buildStaticWindow(document)
154
+
155
+ // Layer 2a: per-element checks over the resolved cascade.
156
+ const runElementCheck = (ruleId, callback) =>
157
+ profile
158
+ ? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
159
+ : callback()
160
+
161
+ for (const rule of STATIC_ELEMENT_RULES) {
162
+ const elements = document.querySelectorAll(rule.selector)
163
+ for (const el of elements) {
164
+ const tag = el.tagName.toLowerCase()
165
+ const style = window.getComputedStyle(el)
166
+ for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window))) {
167
+ if (ruleIds && !ruleIds.includes(f.id)) continue
168
+ findings.push(finding(f.id, filePath, f.snippet))
169
+ }
170
+ }
171
+ }
172
+
173
+ // Layer 2b: page-level checks — only on full pages.
174
+ if (isFullPage(html)) {
175
+ const runPageCheck = (ruleId, callback) =>
176
+ profile
177
+ ? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
178
+ : callback()
179
+ const pushPage = (raw) => {
180
+ for (const f of raw) {
181
+ if (ruleIds && !ruleIds.includes(f.id)) continue
182
+ findings.push(finding(f.id, filePath, f.snippet))
183
+ }
184
+ }
185
+ pushPage(runPageCheck('flat-type-hierarchy', () => checkPageTypography(document, window)))
186
+ pushPage(runPageCheck('layout-rules', () => checkPageLayout(document, window)))
187
+ pushPage(runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document)))
188
+ pushPage(
189
+ runPageCheck('html-patterns', () =>
190
+ // bounce-easing and layout-transition are already covered by the regex
191
+ // matchers on the same markup — drop them here to avoid double-counting.
192
+ checkHtmlPatterns(html).filter(
193
+ (item) => item.id !== 'bounce-easing' && item.id !== 'layout-transition'
194
+ )
195
+ )
196
+ )
197
+ }
198
+
199
+ // Deduplicate (same antipattern + snippet within 2 lines). The text-scan
200
+ // layer and the cascade layer can both surface the same drift.
201
+ const deduped = []
202
+ for (const f of findings) {
203
+ const isDupe = deduped.some(
204
+ (d) => d.antipattern === f.antipattern && d.snippet === f.snippet && Math.abs((d.line || 0) - (f.line || 0)) <= 2
205
+ )
206
+ if (!isDupe) deduped.push(f)
207
+ }
208
+ return deduped
209
+ }
210
+
211
+ export { STATIC_ELEMENT_RULES, detectHtml }
@@ -0,0 +1,192 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+ function sanitizeScreenshotClip(clip, viewport) {
5
+ if (!clip) return null;
6
+ const x = Math.max(0, Math.floor(clip.x || 0));
7
+ const y = Math.max(0, Math.floor(clip.y || 0));
8
+ const width = Math.min(
9
+ Math.max(1, Math.ceil(clip.width || 0)),
10
+ Math.max(1, viewport?.width || 1600),
11
+ );
12
+ const height = Math.min(
13
+ Math.max(1, Math.ceil(clip.height || 0)),
14
+ 320,
15
+ );
16
+ if (width < 1 || height < 1) return null;
17
+ return { x, y, width, height };
18
+ }
19
+
20
+ async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
21
+ return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
22
+ const loadImage = (base64) => new Promise((resolve, reject) => {
23
+ const img = new Image();
24
+ img.onload = () => resolve(img);
25
+ img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
26
+ img.src = `data:image/png;base64,${base64}`;
27
+ });
28
+ const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
29
+ const width = Math.min(before.width, after.width);
30
+ const height = Math.min(before.height, after.height);
31
+ if (width < 1 || height < 1) return null;
32
+
33
+ const canvas = document.createElement('canvas');
34
+ canvas.width = width;
35
+ canvas.height = height;
36
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
37
+ if (!ctx) return null;
38
+
39
+ ctx.drawImage(before, 0, 0, width, height);
40
+ const beforePixels = ctx.getImageData(0, 0, width, height).data;
41
+ ctx.clearRect(0, 0, width, height);
42
+ ctx.drawImage(after, 0, 0, width, height);
43
+ const afterPixels = ctx.getImageData(0, 0, width, height).data;
44
+
45
+ const luminance = ({ r, g, b }) => {
46
+ const convert = c => {
47
+ const v = c / 255;
48
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
49
+ };
50
+ return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
51
+ };
52
+ const ratio = (a, b) => {
53
+ const l1 = luminance(a);
54
+ const l2 = luminance(b);
55
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
56
+ };
57
+
58
+ const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
59
+ ? {
60
+ r: candidate.textColor.r,
61
+ g: candidate.textColor.g,
62
+ b: candidate.textColor.b,
63
+ }
64
+ : null;
65
+ const ratios = [];
66
+ let glyphPixels = 0;
67
+ let strongestDelta = 0;
68
+ for (let i = 0; i < beforePixels.length; i += 4) {
69
+ const delta = Math.abs(beforePixels[i] - afterPixels[i])
70
+ + Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
71
+ + Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
72
+ + Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
73
+ strongestDelta = Math.max(strongestDelta, delta);
74
+ if (delta < 10) continue;
75
+ glyphPixels++;
76
+ const fg = cssTextColor || {
77
+ r: beforePixels[i],
78
+ g: beforePixels[i + 1],
79
+ b: beforePixels[i + 2],
80
+ };
81
+ const bg = {
82
+ r: afterPixels[i],
83
+ g: afterPixels[i + 1],
84
+ b: afterPixels[i + 2],
85
+ };
86
+ ratios.push(ratio(fg, bg));
87
+ }
88
+
89
+ if (ratios.length < 8) {
90
+ return {
91
+ glyphPixels,
92
+ strongestDelta,
93
+ worstRatio: null,
94
+ p10Ratio: null,
95
+ medianRatio: null,
96
+ };
97
+ }
98
+
99
+ ratios.sort((a, b) => a - b);
100
+ const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
101
+ return {
102
+ glyphPixels,
103
+ strongestDelta,
104
+ worstRatio: ratios[0],
105
+ p10Ratio: pick(10),
106
+ medianRatio: pick(50),
107
+ };
108
+ }, { beforeBase64, afterBase64, candidate });
109
+ }
110
+
111
+ async function captureVisualContrastCandidate(page, candidate, viewport) {
112
+ const clip = sanitizeScreenshotClip(candidate.clip, viewport);
113
+ if (!clip) return null;
114
+
115
+ const beforeBase64 = await page.screenshot({
116
+ encoding: 'base64',
117
+ clip,
118
+ captureBeyondViewport: true,
119
+ });
120
+ const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
121
+ const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
122
+ let el;
123
+ try {
124
+ el = document.querySelector(selector);
125
+ } catch {
126
+ return false;
127
+ }
128
+ if (!el) return false;
129
+ let style = document.getElementById('impeccable-visual-contrast-hide-style');
130
+ if (!style) {
131
+ style = document.createElement('style');
132
+ style.id = 'impeccable-visual-contrast-hide-style';
133
+ style.textContent = [
134
+ '[data-impeccable-visual-contrast-target] {',
135
+ ' color: transparent !important;',
136
+ ' -webkit-text-fill-color: transparent !important;',
137
+ ' text-shadow: none !important;',
138
+ '}',
139
+ '[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
140
+ ' background-image: none !important;',
141
+ '}',
142
+ ].join('\n');
143
+ document.head.appendChild(style);
144
+ }
145
+ el.setAttribute('data-impeccable-visual-contrast-target', token);
146
+ if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
147
+ return true;
148
+ }, {
149
+ selector: candidate.selector,
150
+ token,
151
+ backgroundClipText: candidate.backgroundClipText,
152
+ });
153
+ if (!applied) return null;
154
+
155
+ let afterBase64;
156
+ try {
157
+ afterBase64 = await page.screenshot({
158
+ encoding: 'base64',
159
+ clip,
160
+ captureBeyondViewport: true,
161
+ });
162
+ } finally {
163
+ await page.evaluate(({ selector }) => {
164
+ try {
165
+ const el = document.querySelector(selector);
166
+ if (el) {
167
+ el.removeAttribute('data-impeccable-visual-contrast-target');
168
+ el.removeAttribute('data-impeccable-bgclip-text');
169
+ }
170
+ } catch {
171
+ // Ignore invalid or stale selectors during cleanup.
172
+ }
173
+ }, { selector: candidate.selector }).catch(() => {});
174
+ }
175
+
176
+ const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
177
+ if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
178
+ const measuredRatio = metrics.p10Ratio;
179
+ if (measuredRatio >= candidate.threshold) return null;
180
+ const textLabel = candidate.text ? ` "${candidate.text}"` : '';
181
+ const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
182
+ return {
183
+ id: 'low-contrast',
184
+ snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
185
+ };
186
+ }
187
+
188
+ export {
189
+ sanitizeScreenshotClip,
190
+ compareScreenshotContrast,
191
+ captureVisualContrastCandidate,
192
+ };
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import { getAntipattern } from './registry/seven-antipatterns.mjs'
6
+
7
+ /**
8
+ * Build a normalized finding record. Every engine (regex, static-html,
9
+ * browser) produces findings through this helper so the shape is identical
10
+ * regardless of detection path.
11
+ *
12
+ * @returns {{ antipattern: string, name: string, description: string,
13
+ * category: string, severity: string, file: string, line: number,
14
+ * snippet: string }}
15
+ */
16
+ export function finding(id, filePath, snippet, line = 0) {
17
+ const ap = getAntipattern(id)
18
+ return {
19
+ antipattern: id,
20
+ name: ap ? ap.name : id,
21
+ description: ap ? ap.description : '',
22
+ category: ap ? ap.category : 'quality',
23
+ severity: ap && ap.severity ? ap.severity : 'warning',
24
+ file: filePath,
25
+ line,
26
+ snippet,
27
+ }
28
+ }
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // ETUS Digital — Seven Design System. Detector architecture adapted from
3
+ // impeccable (Paul Bakaus, Apache-2.0). See packages/seven-skill/NOTICE.md.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ // ─── File walker ────────────────────────────────────────────────────────────
9
+
10
+ export const SKIP_DIRS = new Set([
11
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.output',
12
+ '.svelte-kit', '__pycache__', '.turbo', '.vercel', '.netlify', '.cache',
13
+ 'coverage', '.worktrees',
14
+ ])
15
+
16
+ // Markdown is intentionally excluded: docs legitimately contain code examples
17
+ // of anti-patterns (the "do not do this" kind), which the detector must not
18
+ // flag. The detector scans code, not documentation.
19
+ export const SCANNABLE_EXTENSIONS = new Set([
20
+ '.html', '.htm', '.css', '.scss', '.less',
21
+ '.jsx', '.tsx', '.js', '.ts', '.mjs', '.cjs',
22
+ '.vue', '.svelte', '.astro',
23
+ ])
24
+
25
+ export const HTML_EXTENSIONS = new Set(['.html', '.htm'])
26
+
27
+ // Story and test files are scaffolding, not shipping UI. Their string
28
+ // literals legitimately carry demo prose and intentional anti-pattern
29
+ // examples — the detector targets production code, so the walker skips them.
30
+ const NON_SHIPPING_FILE_RE = /\.(?:stories|story|test|spec)\.[a-z]+$/i
31
+
32
+ export function isShippingSourceFile(name) {
33
+ return !NON_SHIPPING_FILE_RE.test(name)
34
+ }
35
+
36
+ export function walkDir(dir, maxFiles = 50000) {
37
+ const files = []
38
+ const stack = [dir]
39
+ while (stack.length > 0 && files.length < maxFiles) {
40
+ const current = stack.pop()
41
+ let entries
42
+ try {
43
+ entries = fs.readdirSync(current, { withFileTypes: true })
44
+ } catch {
45
+ continue
46
+ }
47
+ for (const entry of entries) {
48
+ if (SKIP_DIRS.has(entry.name)) continue
49
+ const full = path.join(current, entry.name)
50
+ if (entry.isDirectory()) {
51
+ stack.push(full)
52
+ } else if (
53
+ entry.isFile() &&
54
+ SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()) &&
55
+ isShippingSourceFile(entry.name)
56
+ ) {
57
+ files.push(full)
58
+ }
59
+ }
60
+ }
61
+ return files
62
+ }
63
+
64
+ const PACKAGE_NAME_CACHE = new Map()
65
+
66
+ /**
67
+ * Walk up from `filePath` until a package.json is found. Returns its `name`
68
+ * field, or null. Used for the package-aware rule exemption: inside the Seven
69
+ * monorepo this resolves `@etus/ui`; inside a consumer it resolves files under
70
+ * `node_modules/@etus/ui/`.
71
+ */
72
+ export function findOwningPackageName(filePath) {
73
+ let current = path.dirname(path.resolve(filePath))
74
+ while (current) {
75
+ const cached = PACKAGE_NAME_CACHE.get(current)
76
+ if (cached !== undefined) return cached
77
+ const candidate = path.join(current, 'package.json')
78
+ let exists = false
79
+ try {
80
+ exists = fs.statSync(candidate).isFile()
81
+ } catch {
82
+ exists = false
83
+ }
84
+ if (exists) {
85
+ let name = null
86
+ try {
87
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'))
88
+ name = typeof pkg.name === 'string' ? pkg.name : null
89
+ } catch {
90
+ name = null
91
+ }
92
+ PACKAGE_NAME_CACHE.set(current, name)
93
+ return name
94
+ }
95
+ const parent = path.dirname(current)
96
+ if (parent === current) break
97
+ current = parent
98
+ }
99
+ return null
100
+ }
101
+
102
+ // ─── Import graph (multi-file awareness) ────────────────────────────────────
103
+
104
+ function resolveImport(specifier, fromDir, fileSet) {
105
+ if (!/^[./]/.test(specifier)) return null
106
+ const base = path.resolve(fromDir, specifier)
107
+ if (fileSet.has(base)) return base
108
+ for (const ext of SCANNABLE_EXTENSIONS) {
109
+ if (fileSet.has(base + ext)) return base + ext
110
+ }
111
+ for (const ext of SCANNABLE_EXTENSIONS) {
112
+ const indexFile = path.join(base, 'index' + ext)
113
+ if (fileSet.has(indexFile)) return indexFile
114
+ }
115
+ return null
116
+ }
117
+
118
+ export function buildImportGraph(files) {
119
+ const fileSet = new Set(files)
120
+ const graph = new Map()
121
+ for (const file of files) {
122
+ let content
123
+ try {
124
+ content = fs.readFileSync(file, 'utf-8')
125
+ } catch {
126
+ graph.set(file, new Set())
127
+ continue
128
+ }
129
+ const dir = path.dirname(file)
130
+ const imports = new Set()
131
+ const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g
132
+ let m
133
+ while ((m = esRe.exec(content)) !== null) {
134
+ const resolved = resolveImport(m[1], dir, fileSet)
135
+ if (resolved) imports.add(resolved)
136
+ }
137
+ const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g
138
+ while ((m = cssRe.exec(content)) !== null) {
139
+ const resolved = resolveImport(m[1], dir, fileSet)
140
+ if (resolved) imports.add(resolved)
141
+ }
142
+ graph.set(file, imports)
143
+ }
144
+ return graph
145
+ }
146
+
147
+ // ─── Framework dev server detection ─────────────────────────────────────────
148
+
149
+ export const FRAMEWORK_CONFIGS = [
150
+ { name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { header: 'x-powered-by', value: /next/i } },
151
+ { name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { header: 'x-sveltekit-page', value: null } },
152
+ { name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
153
+ { name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { body: /@vite\/client/ } },
154
+ { name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321, portRe: /port\s*[:=]\s*(\d+)/, fingerprint: { body: /astro/i } },
155
+ ]
156
+
157
+ export function detectFrameworkConfig(dir) {
158
+ let entries
159
+ try {
160
+ entries = fs.readdirSync(dir)
161
+ } catch {
162
+ return null
163
+ }
164
+ const entrySet = new Set(entries)
165
+ for (const cfg of FRAMEWORK_CONFIGS) {
166
+ const match = cfg.files.find((f) => entrySet.has(f))
167
+ if (!match) continue
168
+ const configPath = path.join(dir, match)
169
+ let port = cfg.defaultPort
170
+ try {
171
+ const portMatch = fs.readFileSync(configPath, 'utf-8').match(cfg.portRe)
172
+ if (portMatch) port = parseInt(portMatch[1], 10)
173
+ } catch {
174
+ // use default
175
+ }
176
+ return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint }
177
+ }
178
+ return null
179
+ }
180
+
181
+ /** Probe whether a dev server is listening; optionally verify framework. */
182
+ export async function isPortListening(port, fingerprint = null) {
183
+ if (!fingerprint) {
184
+ const net = await import('node:net')
185
+ return new Promise((resolve) => {
186
+ const sock = net.default.createConnection({ port, host: '127.0.0.1' })
187
+ sock.setTimeout(500)
188
+ sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }) })
189
+ sock.on('error', () => resolve({ listening: false }))
190
+ sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }) })
191
+ })
192
+ }
193
+ try {
194
+ const controller = new AbortController()
195
+ const timeout = setTimeout(() => controller.abort(), 2000)
196
+ const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' })
197
+ clearTimeout(timeout)
198
+ if (fingerprint.header) {
199
+ const val = res.headers.get(fingerprint.header)
200
+ if (val && (!fingerprint.value || fingerprint.value.test(val))) {
201
+ return { listening: true, matched: true }
202
+ }
203
+ }
204
+ if (fingerprint.body) {
205
+ const body = await res.text()
206
+ if (fingerprint.body.test(body)) return { listening: true, matched: true }
207
+ }
208
+ return { listening: true, matched: false }
209
+ } catch {
210
+ return { listening: false }
211
+ }
212
+ }