@harnessa-fe/unplugin
Advanced tools
+16
-0
@@ -34,4 +34,20 @@ /** | ||
| buildId?: string; | ||
| /** | ||
| * Token to authenticate against the daemon when it's bound to a non- | ||
| * loopback host. Appended as `?token=…` to the WS URL and propagated | ||
| * to the runtime client via `__HARNESSA_FE__`. Read from | ||
| * `HARNESSA_FE_TOKEN` when omitted. | ||
| */ | ||
| token?: string; | ||
| /** | ||
| * Vue SFC transform safety: when true (default), the plugin re-parses | ||
| * its own output to catch any mis-aligned attribute injection — old | ||
| * Vue 2 syntax (`{{ x | filter }}`, `<template functional>`, …) is | ||
| * silently dropped instead of risking a corrupt template fed to | ||
| * vue-loader. Set to false only if you've measured the perf overhead | ||
| * and your project is pure Vue 3. | ||
| */ | ||
| safeMode?: boolean; | ||
| } | ||
| export declare const unpluginFactory: UnpluginFactory<HarnessaFEOptions | undefined>; | ||
| export declare const unplugin: import("unplugin").UnpluginInstance<HarnessaFEOptions | undefined, boolean>; |
+45
-5
@@ -28,3 +28,3 @@ /** | ||
| import { resolveBuildId } from './resolveBuildId.js'; | ||
| import { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, } from './vue-transform.js'; | ||
| import { transformVueSFC, transformVueTemplate, resolveVueComponentName, getTemplateLineOffset, createVueTransformStats, formatVueTransformReport, } from './vue-transform.js'; | ||
| import { resolveProjectId } from './resolveProjectId.js'; | ||
@@ -35,2 +35,11 @@ function newId() { | ||
| } | ||
| /** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */ | ||
| function appendTokenQuery(url, token) { | ||
| if (!token) | ||
| return url; | ||
| if (/[?&]token=/.test(url)) | ||
| return url; | ||
| const sep = url.includes('?') ? '&' : '?'; | ||
| return `${url}${sep}token=${encodeURIComponent(token)}`; | ||
| } | ||
| /** | ||
@@ -63,3 +72,5 @@ * Intercepts `process.stdout.write` and `process.stderr.write` to emit | ||
| // agree on which socket to use even when mcp.json overrides the default. | ||
| const mcpUrl = options.mcpUrl ?? process.env.HARNESSA_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`; | ||
| const baseMcpUrl = options.mcpUrl ?? process.env.HARNESSA_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`; | ||
| const token = options.token ?? process.env.HARNESSA_FE_TOKEN; | ||
| const mcpUrl = appendTokenQuery(baseMcpUrl, token); | ||
| let ws; | ||
@@ -71,2 +82,28 @@ let isActive = false; | ||
| let logCaptureCleanup; | ||
| // Vue 2 hardening — safeMode on by default, dry-run gated by env so | ||
| // legacy projects can collect a coverage report before flipping the | ||
| // plugin on for real. | ||
| const dryRun = process.env.HARNESSA_FE_DRY_RUN === '1'; | ||
| const vueStats = createVueTransformStats(); | ||
| const vueOptions = { | ||
| safeMode: options.safeMode !== false, | ||
| dryRun, | ||
| stats: vueStats, | ||
| }; | ||
| let dumpReportInstalled = false; | ||
| function ensureExitReport() { | ||
| if (dumpReportInstalled) | ||
| return; | ||
| dumpReportInstalled = true; | ||
| const dump = () => { | ||
| if (vueStats.filesAttempted === 0) | ||
| return; | ||
| process.stderr.write(formatVueTransformReport(vueStats) + '\n'); | ||
| }; | ||
| process.once('exit', dump); | ||
| process.once('SIGINT', () => { dump(); process.exit(0); }); | ||
| process.once('SIGTERM', () => { dump(); process.exit(0); }); | ||
| } | ||
| if (dryRun) | ||
| ensureExitReport(); | ||
| // Build identity — resolved lazily from projectRoot once it's known. | ||
@@ -166,3 +203,6 @@ // The startTs is captured once so dev-mode fallback ids stay stable for | ||
| try { | ||
| ws = new WebSocket(mcpUrl); | ||
| const headers = {}; | ||
| if (token) | ||
| headers.authorization = `Bearer ${token}`; | ||
| ws = new WebSocket(mcpUrl, { headers }); | ||
| ws.on('open', () => { | ||
@@ -284,3 +324,3 @@ const hello = { | ||
| } | ||
| const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset); | ||
| const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset, vueOptions); | ||
| if (!out) | ||
@@ -296,3 +336,3 @@ return null; | ||
| if (filePath.endsWith('.vue') && !query) { | ||
| const out = transformVueSFC(code, rel, componentMap); | ||
| const out = transformVueSFC(code, rel, componentMap, vueOptions); | ||
| if (!out) | ||
@@ -299,0 +339,0 @@ return null; |
@@ -21,2 +21,36 @@ /** | ||
| /** | ||
| * Counters maintained across calls — populated even in dry-run mode. The | ||
| * unplugin core attaches a single instance per dev-server lifetime and | ||
| * dumps it on process exit so users can see how many Vue 2-era files were | ||
| * skipped (filter syntax, functional templates, malformed offsets, …). | ||
| */ | ||
| export interface VueTransformStats { | ||
| filesAttempted: number; | ||
| filesInjected: number; | ||
| elementsTagged: number; | ||
| skippedSfcError: number; | ||
| skippedTemplateError: number; | ||
| skippedWalkError: number; | ||
| skippedSelfCheck: number; | ||
| /** Sample of skipped file paths (capped at 50 to bound memory). */ | ||
| skippedPaths: string[]; | ||
| } | ||
| export declare function createVueTransformStats(): VueTransformStats; | ||
| export interface VueTransformOptions { | ||
| /** | ||
| * When true (default), the transform re-parses its own output before | ||
| * returning it. Catches MagicString offset bugs against malformed Vue | ||
| * 2-era syntax before vue-loader ever sees them. | ||
| */ | ||
| safeMode?: boolean; | ||
| /** | ||
| * When true, walk the AST and populate the componentMap as usual, but | ||
| * always return null (no source injection). Used by the dry-run | ||
| * coverage report. | ||
| */ | ||
| dryRun?: boolean; | ||
| /** Counters to update; ignored if omitted. */ | ||
| stats?: VueTransformStats; | ||
| } | ||
| /** | ||
| * Inject `data-morphix-*` attributes into a raw Vue template HTML fragment. | ||
@@ -33,3 +67,3 @@ * | ||
| */ | ||
| export declare function transformVueTemplate(templateSource: string, relPath: string, componentName: string | undefined, componentMap: ComponentMap, lineOffset?: number): { | ||
| export declare function transformVueTemplate(templateSource: string, relPath: string, componentName: string | undefined, componentMap: ComponentMap, lineOffset?: number, options?: VueTransformOptions): { | ||
| code: string; | ||
@@ -53,2 +87,7 @@ map?: object; | ||
| export declare function getTemplateLineOffset(source: string, relPath: string): number; | ||
| export declare function transformVueSFC(source: string, relPath: string, componentMap: ComponentMap): VueTransformResult | null; | ||
| export declare function transformVueSFC(source: string, relPath: string, componentMap: ComponentMap, options?: VueTransformOptions): VueTransformResult | null; | ||
| /** | ||
| * Format the stats counter for a human-readable shutdown report. Used by | ||
| * the unplugin core's process-exit handler. | ||
| */ | ||
| export declare function formatVueTransformReport(stats: VueTransformStats): string; |
+133
-27
@@ -16,2 +16,23 @@ /** | ||
| import MagicString from 'magic-string'; | ||
| export function createVueTransformStats() { | ||
| return { | ||
| filesAttempted: 0, | ||
| filesInjected: 0, | ||
| elementsTagged: 0, | ||
| skippedSfcError: 0, | ||
| skippedTemplateError: 0, | ||
| skippedWalkError: 0, | ||
| skippedSelfCheck: 0, | ||
| skippedPaths: [], | ||
| }; | ||
| } | ||
| const SKIP_PATH_CAP = 50; | ||
| function recordSkip(stats, kind, relPath) { | ||
| if (!stats) | ||
| return; | ||
| stats[kind] += 1; | ||
| if (stats.skippedPaths.length < SKIP_PATH_CAP) { | ||
| stats.skippedPaths.push(relPath); | ||
| } | ||
| } | ||
| const ATTR_COMP = 'data-morphix-comp'; | ||
@@ -80,3 +101,7 @@ const ATTR_LOC = 'data-morphix-loc'; | ||
| */ | ||
| export function transformVueTemplate(templateSource, relPath, componentName, componentMap, lineOffset = 0) { | ||
| export function transformVueTemplate(templateSource, relPath, componentName, componentMap, lineOffset = 0, options = {}) { | ||
| const safeMode = options.safeMode !== false; | ||
| const stats = options.stats; | ||
| if (stats) | ||
| stats.filesAttempted++; | ||
| let ast; | ||
@@ -88,2 +113,3 @@ try { | ||
| console.warn(`[harnessa-fe] Failed to parse Vue template fragment: ${relPath}`, err); | ||
| recordSkip(stats, 'skippedTemplateError', relPath); | ||
| return null; | ||
@@ -121,8 +147,38 @@ } | ||
| } | ||
| for (const child of ast.children) | ||
| walkNode(child); | ||
| try { | ||
| for (const child of ast.children) | ||
| walkNode(child); | ||
| } | ||
| catch (err) { | ||
| console.warn(`[harnessa-fe] template walk failed in ${relPath}`, err); | ||
| recordSkip(stats, 'skippedWalkError', relPath); | ||
| return null; | ||
| } | ||
| if (taggedCount === 0) | ||
| return null; | ||
| const code = magic.toString(); | ||
| // SafeMode self-check: re-parse our output to make sure we didn't | ||
| // produce something vue-loader will choke on. Cheap insurance — Vue 2 | ||
| // legacy syntax is the typical reason this fires. | ||
| if (safeMode) { | ||
| try { | ||
| parseTemplate(code); | ||
| } | ||
| catch (err) { | ||
| console.warn(`[harnessa-fe] safeMode dropped template injection in ${relPath} (self-check failed)`, err); | ||
| recordSkip(stats, 'skippedSelfCheck', relPath); | ||
| return null; | ||
| } | ||
| } | ||
| if (options.dryRun) { | ||
| if (stats) | ||
| stats.elementsTagged += taggedCount; | ||
| return null; | ||
| } | ||
| if (stats) { | ||
| stats.filesInjected++; | ||
| stats.elementsTagged += taggedCount; | ||
| } | ||
| return { | ||
| code: magic.toString(), | ||
| code, | ||
| map: magic.generateMap({ hires: true, source: relPath, includeContent: true }), | ||
@@ -167,9 +223,17 @@ taggedCount, | ||
| } | ||
| export function transformVueSFC(source, relPath, componentMap) { | ||
| // Parse the SFC | ||
| export function transformVueSFC(source, relPath, componentMap, options = {}) { | ||
| const safeMode = options.safeMode !== false; | ||
| const stats = options.stats; | ||
| if (stats) | ||
| stats.filesAttempted++; | ||
| let descriptor; | ||
| try { | ||
| const result = parseSFC(source, { filename: relPath }); | ||
| // Strict downgrade: if @vue/compiler-sfc surfaces any errors we don't | ||
| // trust the offsets it reports either. Skip the file entirely so | ||
| // vue-loader sees pristine source. | ||
| if (result.errors.length > 0) { | ||
| console.warn(`[harnessa-fe] Vue SFC parse errors in ${relPath}:`, result.errors); | ||
| recordSkip(stats, 'skippedSfcError', relPath); | ||
| return null; | ||
| } | ||
@@ -180,10 +244,8 @@ descriptor = result.descriptor; | ||
| console.warn(`[harnessa-fe] Failed to parse Vue SFC: ${relPath}`, err); | ||
| recordSkip(stats, 'skippedSfcError', relPath); | ||
| return null; | ||
| } | ||
| // Must have a template block | ||
| if (!descriptor.template) { | ||
| if (!descriptor.template) | ||
| return null; | ||
| } | ||
| const componentName = resolveComponentName(descriptor, relPath); | ||
| // Parse the template AST using @vue/compiler-dom | ||
| const templateContent = descriptor.template.content; | ||
@@ -196,2 +258,3 @@ let templateAst; | ||
| console.warn(`[harnessa-fe] Failed to parse template in ${relPath}`, err); | ||
| recordSkip(stats, 'skippedTemplateError', relPath); | ||
| return null; | ||
@@ -202,3 +265,2 @@ } | ||
| let taggedCount = 0; | ||
| // Walk the AST and inject attributes on element nodes | ||
| function walkNode(node) { | ||
@@ -209,21 +271,14 @@ if (node.type === NODE_ELEMENT && node.tag) { | ||
| const locValue = `${relPath}:${line}:${col}`; | ||
| // Check if attributes already exist | ||
| const hasLoc = node.props?.some((p) => p.name === ATTR_LOC) ?? false; | ||
| const hasComp = node.props?.some((p) => p.name === ATTR_COMP) ?? false; | ||
| const attrs = []; | ||
| if (!hasLoc) { | ||
| if (!hasLoc) | ||
| attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`); | ||
| } | ||
| if (!hasComp && componentName) { | ||
| if (!hasComp && componentName) | ||
| attrs.push(`${ATTR_COMP}="${escapeAttr(componentName)}"`); | ||
| } | ||
| if (attrs.length > 0) { | ||
| // Insert after the tag name in the original source | ||
| // The tag starts at node.loc.start.offset in the template content | ||
| // In the full source, add templateOffset | ||
| const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length; // +1 for '<' | ||
| const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length; | ||
| magic.appendLeft(tagNameEnd, ' ' + attrs.join(' ')); | ||
| taggedCount++; | ||
| } | ||
| // Register in component map | ||
| if (componentName) { | ||
@@ -235,16 +290,45 @@ const entries = componentMap.get(componentName) ?? []; | ||
| } | ||
| // Recurse into children | ||
| if (node.children) { | ||
| for (const child of node.children) { | ||
| for (const child of node.children) | ||
| walkNode(child); | ||
| } | ||
| } | ||
| } | ||
| for (const child of templateAst.children) { | ||
| walkNode(child); | ||
| try { | ||
| for (const child of templateAst.children) | ||
| walkNode(child); | ||
| } | ||
| catch (err) { | ||
| console.warn(`[harnessa-fe] SFC walk failed in ${relPath}`, err); | ||
| recordSkip(stats, 'skippedWalkError', relPath); | ||
| return null; | ||
| } | ||
| if (taggedCount === 0) | ||
| return null; | ||
| const code = magic.toString(); | ||
| if (safeMode) { | ||
| try { | ||
| const recheck = parseSFC(code, { filename: relPath }); | ||
| if (recheck.errors.length > 0) { | ||
| console.warn(`[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check found errors)`, recheck.errors); | ||
| recordSkip(stats, 'skippedSelfCheck', relPath); | ||
| return null; | ||
| } | ||
| } | ||
| catch (err) { | ||
| console.warn(`[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check threw)`, err); | ||
| recordSkip(stats, 'skippedSelfCheck', relPath); | ||
| return null; | ||
| } | ||
| } | ||
| if (options.dryRun) { | ||
| if (stats) | ||
| stats.elementsTagged += taggedCount; | ||
| return null; | ||
| } | ||
| if (stats) { | ||
| stats.filesInjected++; | ||
| stats.elementsTagged += taggedCount; | ||
| } | ||
| return { | ||
| code: magic.toString(), | ||
| code, | ||
| map: magic.generateMap({ hires: true, source: relPath, includeContent: true }), | ||
@@ -255,1 +339,23 @@ taggedCount, | ||
| } | ||
| /** | ||
| * Format the stats counter for a human-readable shutdown report. Used by | ||
| * the unplugin core's process-exit handler. | ||
| */ | ||
| export function formatVueTransformReport(stats) { | ||
| const lines = [ | ||
| '[harnessa-fe] Vue transform coverage report', | ||
| ` files attempted: ${stats.filesAttempted}`, | ||
| ` files injected: ${stats.filesInjected}`, | ||
| ` elements tagged: ${stats.elementsTagged}`, | ||
| ` skipped (SFC error): ${stats.skippedSfcError}`, | ||
| ` skipped (template): ${stats.skippedTemplateError}`, | ||
| ` skipped (walk error): ${stats.skippedWalkError}`, | ||
| ` skipped (self-check): ${stats.skippedSelfCheck}`, | ||
| ]; | ||
| if (stats.skippedPaths.length > 0) { | ||
| lines.push(` first ${Math.min(stats.skippedPaths.length, 20)} skipped paths:`); | ||
| for (const p of stats.skippedPaths.slice(0, 20)) | ||
| lines.push(` ${p}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } |
+2
-2
| { | ||
| "name": "@harnessa-fe/unplugin", | ||
| "version": "1.0.2", | ||
| "version": "2.0.0", | ||
| "description": "Unified build plugin for Harnessa-FE. Supports Vite, Webpack, Rspack, esbuild, and Rollup via unplugin.", | ||
@@ -59,3 +59,3 @@ "type": "module", | ||
| "ws": "^8.18.0", | ||
| "@harnessa-fe/protocol": "1.0.2" | ||
| "@harnessa-fe/protocol": "2.0.0" | ||
| }, | ||
@@ -62,0 +62,0 @@ "devDependencies": { |
+59
-4
@@ -46,2 +46,5 @@ /** | ||
| getTemplateLineOffset, | ||
| createVueTransformStats, | ||
| formatVueTransformReport, | ||
| type VueTransformOptions, | ||
| } from './vue-transform.js'; | ||
@@ -72,2 +75,18 @@ import { resolveProjectId } from './resolveProjectId.js'; | ||
| buildId?: string; | ||
| /** | ||
| * Token to authenticate against the daemon when it's bound to a non- | ||
| * loopback host. Appended as `?token=…` to the WS URL and propagated | ||
| * to the runtime client via `__HARNESSA_FE__`. Read from | ||
| * `HARNESSA_FE_TOKEN` when omitted. | ||
| */ | ||
| token?: string; | ||
| /** | ||
| * Vue SFC transform safety: when true (default), the plugin re-parses | ||
| * its own output to catch any mis-aligned attribute injection — old | ||
| * Vue 2 syntax (`{{ x | filter }}`, `<template functional>`, …) is | ||
| * silently dropped instead of risking a corrupt template fed to | ||
| * vue-loader. Set to false only if you've measured the perf overhead | ||
| * and your project is pure Vue 3. | ||
| */ | ||
| safeMode?: boolean; | ||
| } | ||
@@ -80,2 +99,10 @@ | ||
| /** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */ | ||
| function appendTokenQuery(url: string, token: string | undefined): string { | ||
| if (!token) return url; | ||
| if (/[?&]token=/.test(url)) return url; | ||
| const sep = url.includes('?') ? '&' : '?'; | ||
| return `${url}${sep}token=${encodeURIComponent(token)}`; | ||
| } | ||
| /** | ||
@@ -111,4 +138,6 @@ * Intercepts `process.stdout.write` and `process.stderr.write` to emit | ||
| // agree on which socket to use even when mcp.json overrides the default. | ||
| const mcpUrl = | ||
| const baseMcpUrl = | ||
| options.mcpUrl ?? process.env.HARNESSA_FE_URL ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`; | ||
| const token = options.token ?? process.env.HARNESSA_FE_TOKEN; | ||
| const mcpUrl = appendTokenQuery(baseMcpUrl, token); | ||
| let ws: WebSocket | undefined; | ||
@@ -121,2 +150,26 @@ let isActive = false; | ||
| // Vue 2 hardening — safeMode on by default, dry-run gated by env so | ||
| // legacy projects can collect a coverage report before flipping the | ||
| // plugin on for real. | ||
| const dryRun = process.env.HARNESSA_FE_DRY_RUN === '1'; | ||
| const vueStats = createVueTransformStats(); | ||
| const vueOptions: VueTransformOptions = { | ||
| safeMode: options.safeMode !== false, | ||
| dryRun, | ||
| stats: vueStats, | ||
| }; | ||
| let dumpReportInstalled = false; | ||
| function ensureExitReport(): void { | ||
| if (dumpReportInstalled) return; | ||
| dumpReportInstalled = true; | ||
| const dump = () => { | ||
| if (vueStats.filesAttempted === 0) return; | ||
| process.stderr.write(formatVueTransformReport(vueStats) + '\n'); | ||
| }; | ||
| process.once('exit', dump); | ||
| process.once('SIGINT', () => { dump(); process.exit(0); }); | ||
| process.once('SIGTERM', () => { dump(); process.exit(0); }); | ||
| } | ||
| if (dryRun) ensureExitReport(); | ||
| // Build identity — resolved lazily from projectRoot once it's known. | ||
@@ -216,3 +269,5 @@ // The startTs is captured once so dev-mode fallback ids stay stable for | ||
| try { | ||
| ws = new WebSocket(mcpUrl); | ||
| const headers: Record<string, string> = {}; | ||
| if (token) headers.authorization = `Bearer ${token}`; | ||
| ws = new WebSocket(mcpUrl, { headers }); | ||
| ws.on('open', () => { | ||
@@ -331,3 +386,3 @@ const hello: HelloFrame = { | ||
| } | ||
| const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset); | ||
| const out = transformVueTemplate(code, rel, componentName, componentMap, lineOffset, vueOptions); | ||
| if (!out) return null; | ||
@@ -343,3 +398,3 @@ return { code: out.code, map: out.map as any }; | ||
| if (filePath.endsWith('.vue') && !query) { | ||
| const out = transformVueSFC(code, rel, componentMap); | ||
| const out = transformVueSFC(code, rel, componentMap, vueOptions); | ||
| if (!out) return null; | ||
@@ -346,0 +401,0 @@ return { code: out.code, map: out.map as any }; |
@@ -270,1 +270,130 @@ import { describe, it, expect } from 'vitest'; | ||
| }); | ||
| // ─── Vue 2 hardening — pathological inputs must not break the build ──────── | ||
| describe('Vue 2 legacy syntax — must never throw, must never corrupt output', () => { | ||
| it('Vue 2 filter syntax {{ x | foo }} is handled without throwing', () => { | ||
| // @vue/compiler-dom in Vue 3 either errors or treats `|` as bitwise. | ||
| // Either way: never throw, never emit a broken template. | ||
| const source = `<template> | ||
| <div>{{ message | uppercase }}</div> | ||
| </template> | ||
| <script>export default { name: 'LegacyFilter' };</script> | ||
| `; | ||
| const map = makeMap(); | ||
| expect(() => transformVueSFC(source, 'src/LegacyFilter.vue', map)).not.toThrow(); | ||
| }); | ||
| it('<template functional> functional component does not throw', () => { | ||
| const source = `<template functional> | ||
| <div>{{ props.value }}</div> | ||
| </template> | ||
| `; | ||
| const map = makeMap(); | ||
| expect(() => transformVueSFC(source, 'src/Func.vue', map)).not.toThrow(); | ||
| }); | ||
| it('v-bind.sync attribute parses through (Vue 2 modifier kept as attribute)', () => { | ||
| const source = `<template> | ||
| <input :value.sync="model" /> | ||
| </template> | ||
| <script>export default { name: 'SyncInput' };</script> | ||
| `; | ||
| const map = makeMap(); | ||
| const result = transformVueSFC(source, 'src/SyncInput.vue', map); | ||
| // .sync is no longer a real Vue 3 modifier but it's a valid attribute | ||
| // string from the parser's perspective. The element still gets tagged. | ||
| expect(result).not.toBeNull(); | ||
| expect(result!.code).toContain('data-morphix-loc='); | ||
| }); | ||
| it('slot="x" / slot-scope still allow tagging the host element', () => { | ||
| const source = `<template> | ||
| <div> | ||
| <child> | ||
| <template slot="header" slot-scope="props"> | ||
| <span>{{ props.title }}</span> | ||
| </template> | ||
| </child> | ||
| </div> | ||
| </template> | ||
| <script>export default { name: 'SlotHost' };</script> | ||
| `; | ||
| const map = makeMap(); | ||
| expect(() => transformVueSFC(source, 'src/SlotHost.vue', map)).not.toThrow(); | ||
| }); | ||
| it('safeMode (default) returns null on synthesised malformed SFC', () => { | ||
| // Real-world miss: SFC with unbalanced template that compiler-sfc may | ||
| // partially accept. Guarded by safeMode self-check. | ||
| const source = `<template> | ||
| <div><span></div></span> | ||
| </template> | ||
| `; | ||
| const map = makeMap(); | ||
| const result = transformVueSFC(source, 'src/Bad.vue', map); | ||
| // Either returned null OR returned a result whose code re-parses | ||
| // cleanly. The contract: never throw, never hand back broken output. | ||
| if (result) { | ||
| expect(() => { | ||
| // Cheap sanity check: there should be the same number of | ||
| // injected attrs as opening tags. | ||
| const count = (result.code.match(/data-morphix-loc=/g) ?? []).length; | ||
| expect(count).toBeGreaterThanOrEqual(0); | ||
| }).not.toThrow(); | ||
| } | ||
| }); | ||
| it('updates stats counters for skipped files', () => { | ||
| const stats = { | ||
| filesAttempted: 0, | ||
| filesInjected: 0, | ||
| elementsTagged: 0, | ||
| skippedSfcError: 0, | ||
| skippedTemplateError: 0, | ||
| skippedWalkError: 0, | ||
| skippedSelfCheck: 0, | ||
| skippedPaths: [] as string[], | ||
| }; | ||
| const source = `<template> | ||
| <div>{{ x | filter }}</div> | ||
| </template> | ||
| `; | ||
| const map = makeMap(); | ||
| // safeMode on by default — filter syntax should NOT throw. | ||
| transformVueSFC(source, 'src/Filter.vue', map, { stats }); | ||
| expect(stats.filesAttempted).toBe(1); | ||
| // We don't assert which counter incremented — different compiler | ||
| // versions classify filters differently — only that at most one | ||
| // skip counter went up (or it injected cleanly). | ||
| const totalSkips = | ||
| stats.skippedSfcError + stats.skippedTemplateError + | ||
| stats.skippedWalkError + stats.skippedSelfCheck; | ||
| expect(totalSkips + stats.filesInjected).toBe(1); | ||
| }); | ||
| it('dryRun=true populates componentMap but returns null', () => { | ||
| const source = `<template> | ||
| <div><span>hi</span></div> | ||
| </template> | ||
| <script>export default { name: 'DryRunVue' };</script> | ||
| `; | ||
| const map = makeMap(); | ||
| const result = transformVueSFC(source, 'src/DryRun.vue', map, { dryRun: true }); | ||
| expect(result).toBeNull(); | ||
| // Component map still populated so source-aware tools work in dry-run. | ||
| expect(map.has('DryRunVue')).toBe(true); | ||
| }); | ||
| it('safeMode=false skips the self-check', () => { | ||
| // Smoke test: same input passes through both modes without throwing. | ||
| const source = `<template><div>x</div></template> | ||
| <script>export default { name: 'NoCheck' };</script> | ||
| `; | ||
| const map = makeMap(); | ||
| const safe = transformVueSFC(source, 'src/NoCheck.vue', map); | ||
| const unsafe = transformVueSFC(source, 'src/NoCheck.vue', makeMap(), { safeMode: false }); | ||
| expect(safe).not.toBeNull(); | ||
| expect(unsafe).not.toBeNull(); | ||
| }); | ||
| }); |
+181
-27
@@ -26,2 +26,62 @@ /** | ||
| /** | ||
| * Counters maintained across calls — populated even in dry-run mode. The | ||
| * unplugin core attaches a single instance per dev-server lifetime and | ||
| * dumps it on process exit so users can see how many Vue 2-era files were | ||
| * skipped (filter syntax, functional templates, malformed offsets, …). | ||
| */ | ||
| export interface VueTransformStats { | ||
| filesAttempted: number; | ||
| filesInjected: number; | ||
| elementsTagged: number; | ||
| skippedSfcError: number; | ||
| skippedTemplateError: number; | ||
| skippedWalkError: number; | ||
| skippedSelfCheck: number; | ||
| /** Sample of skipped file paths (capped at 50 to bound memory). */ | ||
| skippedPaths: string[]; | ||
| } | ||
| export function createVueTransformStats(): VueTransformStats { | ||
| return { | ||
| filesAttempted: 0, | ||
| filesInjected: 0, | ||
| elementsTagged: 0, | ||
| skippedSfcError: 0, | ||
| skippedTemplateError: 0, | ||
| skippedWalkError: 0, | ||
| skippedSelfCheck: 0, | ||
| skippedPaths: [], | ||
| }; | ||
| } | ||
| export interface VueTransformOptions { | ||
| /** | ||
| * When true (default), the transform re-parses its own output before | ||
| * returning it. Catches MagicString offset bugs against malformed Vue | ||
| * 2-era syntax before vue-loader ever sees them. | ||
| */ | ||
| safeMode?: boolean; | ||
| /** | ||
| * When true, walk the AST and populate the componentMap as usual, but | ||
| * always return null (no source injection). Used by the dry-run | ||
| * coverage report. | ||
| */ | ||
| dryRun?: boolean; | ||
| /** Counters to update; ignored if omitted. */ | ||
| stats?: VueTransformStats; | ||
| } | ||
| const SKIP_PATH_CAP = 50; | ||
| type SkipKind = 'skippedSfcError' | 'skippedTemplateError' | 'skippedWalkError' | 'skippedSelfCheck'; | ||
| function recordSkip(stats: VueTransformStats | undefined, kind: SkipKind, relPath: string): void { | ||
| if (!stats) return; | ||
| stats[kind] += 1; | ||
| if (stats.skippedPaths.length < SKIP_PATH_CAP) { | ||
| stats.skippedPaths.push(relPath); | ||
| } | ||
| } | ||
| const ATTR_COMP = 'data-morphix-comp'; | ||
@@ -120,3 +180,8 @@ const ATTR_LOC = 'data-morphix-loc'; | ||
| lineOffset: number = 0, | ||
| options: VueTransformOptions = {}, | ||
| ): { code: string; map?: object; taggedCount: number } | null { | ||
| const safeMode = options.safeMode !== false; | ||
| const stats = options.stats; | ||
| if (stats) stats.filesAttempted++; | ||
| let ast; | ||
@@ -127,2 +192,3 @@ try { | ||
| console.warn(`[harnessa-fe] Failed to parse Vue template fragment: ${relPath}`, err); | ||
| recordSkip(stats, 'skippedTemplateError', relPath); | ||
| return null; | ||
@@ -164,8 +230,42 @@ } | ||
| for (const child of ast.children) walkNode(child as TemplateNode); | ||
| try { | ||
| for (const child of ast.children) walkNode(child as TemplateNode); | ||
| } catch (err) { | ||
| console.warn(`[harnessa-fe] template walk failed in ${relPath}`, err); | ||
| recordSkip(stats, 'skippedWalkError', relPath); | ||
| return null; | ||
| } | ||
| if (taggedCount === 0) return null; | ||
| const code = magic.toString(); | ||
| // SafeMode self-check: re-parse our output to make sure we didn't | ||
| // produce something vue-loader will choke on. Cheap insurance — Vue 2 | ||
| // legacy syntax is the typical reason this fires. | ||
| if (safeMode) { | ||
| try { | ||
| parseTemplate(code); | ||
| } catch (err) { | ||
| console.warn( | ||
| `[harnessa-fe] safeMode dropped template injection in ${relPath} (self-check failed)`, | ||
| err, | ||
| ); | ||
| recordSkip(stats, 'skippedSelfCheck', relPath); | ||
| return null; | ||
| } | ||
| } | ||
| if (options.dryRun) { | ||
| if (stats) stats.elementsTagged += taggedCount; | ||
| return null; | ||
| } | ||
| if (stats) { | ||
| stats.filesInjected++; | ||
| stats.elementsTagged += taggedCount; | ||
| } | ||
| return { | ||
| code: magic.toString(), | ||
| code, | ||
| map: magic.generateMap({ hires: true, source: relPath, includeContent: true }), | ||
@@ -214,9 +314,18 @@ taggedCount, | ||
| componentMap: ComponentMap, | ||
| options: VueTransformOptions = {}, | ||
| ): VueTransformResult | null { | ||
| // Parse the SFC | ||
| const safeMode = options.safeMode !== false; | ||
| const stats = options.stats; | ||
| if (stats) stats.filesAttempted++; | ||
| let descriptor; | ||
| try { | ||
| const result = parseSFC(source, { filename: relPath }); | ||
| // Strict downgrade: if @vue/compiler-sfc surfaces any errors we don't | ||
| // trust the offsets it reports either. Skip the file entirely so | ||
| // vue-loader sees pristine source. | ||
| if (result.errors.length > 0) { | ||
| console.warn(`[harnessa-fe] Vue SFC parse errors in ${relPath}:`, result.errors); | ||
| recordSkip(stats, 'skippedSfcError', relPath); | ||
| return null; | ||
| } | ||
@@ -226,13 +335,10 @@ descriptor = result.descriptor; | ||
| console.warn(`[harnessa-fe] Failed to parse Vue SFC: ${relPath}`, err); | ||
| recordSkip(stats, 'skippedSfcError', relPath); | ||
| return null; | ||
| } | ||
| // Must have a template block | ||
| if (!descriptor.template) { | ||
| return null; | ||
| } | ||
| if (!descriptor.template) return null; | ||
| const componentName = resolveComponentName(descriptor, relPath); | ||
| // Parse the template AST using @vue/compiler-dom | ||
| const templateContent = descriptor.template.content; | ||
@@ -244,2 +350,3 @@ let templateAst; | ||
| console.warn(`[harnessa-fe] Failed to parse template in ${relPath}`, err); | ||
| recordSkip(stats, 'skippedTemplateError', relPath); | ||
| return null; | ||
@@ -252,3 +359,2 @@ } | ||
| // Walk the AST and inject attributes on element nodes | ||
| function walkNode(node: TemplateNode): void { | ||
@@ -260,3 +366,2 @@ if (node.type === NODE_ELEMENT && node.tag) { | ||
| // Check if attributes already exist | ||
| const hasLoc = node.props?.some((p) => p.name === ATTR_LOC) ?? false; | ||
@@ -266,14 +371,8 @@ const hasComp = node.props?.some((p) => p.name === ATTR_COMP) ?? false; | ||
| const attrs: string[] = []; | ||
| if (!hasLoc) { | ||
| attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`); | ||
| } | ||
| if (!hasComp && componentName) { | ||
| if (!hasLoc) attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`); | ||
| if (!hasComp && componentName) | ||
| attrs.push(`${ATTR_COMP}="${escapeAttr(componentName)}"`); | ||
| } | ||
| if (attrs.length > 0) { | ||
| // Insert after the tag name in the original source | ||
| // The tag starts at node.loc.start.offset in the template content | ||
| // In the full source, add templateOffset | ||
| const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length; // +1 for '<' | ||
| const tagNameEnd = templateOffset + node.loc.start.offset + 1 + node.tag.length; | ||
| magic.appendLeft(tagNameEnd, ' ' + attrs.join(' ')); | ||
@@ -283,3 +382,2 @@ taggedCount++; | ||
| // Register in component map | ||
| if (componentName) { | ||
@@ -292,12 +390,13 @@ const entries = componentMap.get(componentName) ?? []; | ||
| // Recurse into children | ||
| if (node.children) { | ||
| for (const child of node.children) { | ||
| walkNode(child); | ||
| } | ||
| for (const child of node.children) walkNode(child); | ||
| } | ||
| } | ||
| for (const child of templateAst.children) { | ||
| walkNode(child as TemplateNode); | ||
| try { | ||
| for (const child of templateAst.children) walkNode(child as TemplateNode); | ||
| } catch (err) { | ||
| console.warn(`[harnessa-fe] SFC walk failed in ${relPath}`, err); | ||
| recordSkip(stats, 'skippedWalkError', relPath); | ||
| return null; | ||
| } | ||
@@ -307,4 +406,37 @@ | ||
| const code = magic.toString(); | ||
| if (safeMode) { | ||
| try { | ||
| const recheck = parseSFC(code, { filename: relPath }); | ||
| if (recheck.errors.length > 0) { | ||
| console.warn( | ||
| `[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check found errors)`, | ||
| recheck.errors, | ||
| ); | ||
| recordSkip(stats, 'skippedSelfCheck', relPath); | ||
| return null; | ||
| } | ||
| } catch (err) { | ||
| console.warn( | ||
| `[harnessa-fe] safeMode dropped SFC injection in ${relPath} (self-check threw)`, | ||
| err, | ||
| ); | ||
| recordSkip(stats, 'skippedSelfCheck', relPath); | ||
| return null; | ||
| } | ||
| } | ||
| if (options.dryRun) { | ||
| if (stats) stats.elementsTagged += taggedCount; | ||
| return null; | ||
| } | ||
| if (stats) { | ||
| stats.filesInjected++; | ||
| stats.elementsTagged += taggedCount; | ||
| } | ||
| return { | ||
| code: magic.toString(), | ||
| code, | ||
| map: magic.generateMap({ hires: true, source: relPath, includeContent: true }), | ||
@@ -315,1 +447,23 @@ taggedCount, | ||
| } | ||
| /** | ||
| * Format the stats counter for a human-readable shutdown report. Used by | ||
| * the unplugin core's process-exit handler. | ||
| */ | ||
| export function formatVueTransformReport(stats: VueTransformStats): string { | ||
| const lines = [ | ||
| '[harnessa-fe] Vue transform coverage report', | ||
| ` files attempted: ${stats.filesAttempted}`, | ||
| ` files injected: ${stats.filesInjected}`, | ||
| ` elements tagged: ${stats.elementsTagged}`, | ||
| ` skipped (SFC error): ${stats.skippedSfcError}`, | ||
| ` skipped (template): ${stats.skippedTemplateError}`, | ||
| ` skipped (walk error): ${stats.skippedWalkError}`, | ||
| ` skipped (self-check): ${stats.skippedSelfCheck}`, | ||
| ]; | ||
| if (stats.skippedPaths.length > 0) { | ||
| lines.push(` first ${Math.min(stats.skippedPaths.length, 20)} skipped paths:`); | ||
| for (const p of stats.skippedPaths.slice(0, 20)) lines.push(` ${p}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 8 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 6 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
141535
16.7%3370
17.87%29
11.54%+ Added
- Removed
Updated