Lint CLI code: no-raw-console-format rule, fix all violations

Add no-raw-console-format lint rule for CLI commands — bans template
literals and string concatenation in console.log, forcing use of
agentfmt helpers (bold, dim, kv, entity, fmtTree, fmtList, etc.).

Extend lint target to include packages/cli/src/.

Fix all 22 lint errors in CLI code:
- Replace string concatenation with agentfmt (clusters, typography, variables)
- Mark optional CLI args as required: false (citty type accuracy)
- Remove unnecessary type assertions and optional chains
- Merge duplicate @open-pencil/core imports in headless.ts
- Extract exportViaApp/exportFromFile to reduce export.ts complexity
- Remove unused imports (formatType, printError)
- void runMain() for floating promise
This commit is contained in:
Danila Poyarkov 2026-03-09 12:54:53 +03:00
parent 4bdc533e3d
commit d9154ef29d
14 changed files with 184 additions and 147 deletions

View file

@ -147,6 +147,46 @@ const noHandRolledColor = {
},
}
const noRawConsoleFormat = {
meta: {
docs: {
description:
'Disallow hand-rolled formatting in console.log — use agentfmt helpers (bold, dim, kv, entity, fmtTree, fmtList, etc.)',
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee?.type !== 'MemberExpression' ||
node.callee.object?.type !== 'Identifier' ||
node.callee.object.name !== 'console' ||
node.callee.property?.type !== 'Identifier' ||
node.callee.property.name !== 'log'
) return
if (!node.arguments?.length) return
for (const arg of node.arguments) {
if (arg.type === 'TemplateLiteral' && arg.expressions?.length > 0) {
context.report({
node,
message: 'Use agentfmt helpers (bold, dim, kv, entity, etc.) instead of template literals in console.log.',
})
return
}
if (arg.type === 'BinaryExpression' && arg.operator === '+') {
context.report({
node,
message: 'Use agentfmt helpers (bold, dim, kv, entity, etc.) instead of string concatenation in console.log.',
})
return
}
}
},
}
},
}
const plugin = {
meta: { name: 'open-pencil' },
rules: {
@ -154,6 +194,7 @@ const plugin = {
'no-structuredclone-scene-arrays': noStructuredCloneSceneArrays,
'no-math-random': noMathRandom,
'no-hand-rolled-color': noHandRolledColor,
'no-raw-console-format': noRawConsoleFormat,
},
}

View file

@ -77,7 +77,8 @@
}],
"open-pencil/no-structuredclone-scene-arrays": "error",
"open-pencil/no-math-random": "error",
"open-pencil/no-hand-rolled-color": "error"
"open-pencil/no-hand-rolled-color": "error",
"open-pencil/no-raw-console-format": "off"
},
"overrides": [
{
@ -103,6 +104,12 @@
"rules": {
"max-lines": "off"
}
},
{
"files": ["packages/cli/src/commands/**"],
"rules": {
"open-pencil/no-raw-console-format": "error"
}
}
],
"ignorePatterns": ["node_modules", "dist", "desktop", "*.config.*"]

View file

@ -12,7 +12,7 @@
"build": "bun run lint && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/",
"lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/cli/src/",
"format": "oxfmt --write src/",
"check": "bun run lint",
"test": "playwright test --project=openpencil",

View file

@ -9,7 +9,7 @@ import type { AnalyzeClustersResult } from '@open-pencil/core'
function calcConfidence(nodes: Array<{ width: number; height: number; childCount: number }>): number {
if (nodes.length < 2) return 100
const base = nodes[0]!
const base = nodes[0]
let score = 0
for (const node of nodes.slice(1)) {
const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height)
@ -24,7 +24,7 @@ function calcConfidence(nodes: Array<{ width: number; height: number; childCount
function formatSignature(sig: string): string {
const [typeSize, children] = sig.split('|')
const type = typeSize?.split(':')[0]
const type = typeSize.split(':')[0]
if (!type) return sig
const typeName = type.charAt(0) + type.slice(1).toLowerCase()
if (!children) return typeName
@ -73,7 +73,7 @@ export default defineCommand({
console.log('')
const items = data.clusters.map((c) => {
const first = c.nodes[0]!
const first = c.nodes[0]
const confidence = calcConfidence(c.nodes)
const widths = c.nodes.map((n) => n.width)
@ -102,10 +102,11 @@ export default defineCommand({
const clusteredNodes = data.clusters.reduce((sum, c) => sum + c.nodes.length, 0)
console.log('')
console.log(
fmtSummary({ clusters: data.clusters.length }) +
` from ${data.totalNodes} nodes (${clusteredNodes} clustered)`
)
console.log(fmtSummary({
clusters: data.clusters.length,
'total nodes': data.totalNodes,
clustered: clusteredNodes
}))
console.log('')
}
})

View file

@ -91,7 +91,7 @@ export default defineCommand({
}
console.log('')
console.log(fmtSummary({ 'unique styles': data.styles.length }) + ` from ${data.totalTextNodes} text nodes`)
console.log(fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes }))
console.log('')
}
})

View file

@ -6,9 +6,17 @@ import { loadDocument } from '../headless'
import { isAppMode, requireFile, rpc } from '../app-client'
import { printError } from '../format'
function printResult(value: unknown, json: boolean) {
if (json || !process.stdout.isTTY) {
console.log(JSON.stringify(value, null, 2))
} else {
console.log(value)
}
}
function serializeResult(value: unknown): unknown {
if (value === undefined || value === null) return value
if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') {
if (typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') {
return value.toJSON()
}
if (Array.isArray(value)) return value.map(serializeResult)
@ -22,7 +30,7 @@ export default defineCommand({
code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
stdin: { type: 'boolean', description: 'Read code from stdin' },
write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' },
output: { type: 'string', alias: 'o', description: 'Write to a different file' },
output: { type: 'string', alias: 'o', description: 'Write to a different file', required: false },
json: { type: 'boolean', description: 'Output as JSON' },
quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' },
},
@ -41,13 +49,9 @@ export default defineCommand({
}
if (isAppMode(args.file)) {
const result = await rpc<unknown>('eval', { code })
const result = await rpc('eval', { code })
if (!args.quiet && result !== undefined && result !== null) {
if (args.json || !process.stdout.isTTY) {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(result)
}
printResult(result, !!args.json)
}
return
}
@ -56,6 +60,7 @@ export default defineCommand({
const graph = await loadDocument(file)
const figma = new FigmaAPI(graph)
// eslint-disable-next-line no-empty-function -- needed to get AsyncFunction constructor
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
const wrappedCode = code.trim().startsWith('return')
? code
@ -71,17 +76,12 @@ export default defineCommand({
}
if (!args.quiet && result !== undefined) {
const serialized = serializeResult(result)
if (args.json || !process.stdout.isTTY) {
console.log(JSON.stringify(serialized, null, 2))
} else {
console.log(serialized)
}
printResult(serializeResult(result), !!args.json)
}
if (args.write || args.output) {
const { exportFigFile } = await import('@open-pencil/core')
const outPath = args.output ?? file
const outPath = args.output ? args.output : file
const data = await exportFigFile(graph)
await Bun.write(outPath, new Uint8Array(data))
if (!args.quiet) {

View file

@ -12,16 +12,109 @@ const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
const JSX_STYLES = ['openpencil', 'tailwind']
interface ExportArgs {
file?: string
output?: string
format: string
scale: string
quality?: string
page?: string
node?: string
style: string
thumbnail?: boolean
width: string
height: string
}
async function writeAndLog(path: string, content: string | Uint8Array) {
await Bun.write(path, content)
const size = typeof content === 'string' ? content.length : content.length
console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`))
}
async function exportViaApp(format: string, args: ExportArgs) {
if (format === 'SVG') {
const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } })
if (!result.svg) { printError('Nothing to export.'); process.exit(1) }
await writeAndLog(resolve(args.output ?? 'export.svg'), result.svg)
return
}
if (format === 'JSX') {
const result = await rpc<{ jsx: string }>('export_jsx', { nodeIds: args.node ? [args.node] : undefined, style: args.style })
if (!result.jsx) { printError('Nothing to export.'); process.exit(1) }
await writeAndLog(resolve(args.output ?? 'export.jsx'), result.jsx)
return
}
const result = await rpc<{ base64: string }>('export', {
nodeIds: args.node ? [args.node] : undefined,
scale: Number(args.scale),
format: format.toLowerCase()
})
const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
await writeAndLog(resolve(args.output ?? `export.${ext}`), data)
}
async function exportFromFile(format: string, args: ExportArgs) {
const file = requireFile(args.file)
const graph = await loadDocument(file)
await loadFonts(graph)
const pages = graph.getPages()
const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
if (!page) { printError(`Page "${args.page}" not found.`); process.exit(1) }
const defaultName = basename(file, extname(file))
if (format === 'JSX') {
const nodeIds = args.node ? [args.node] : page.childIds
const jsxStr = nodeIds.length === 1
? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat)
: selectionToJSX(nodeIds, graph, args.style as JSXFormat)
if (!jsxStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr)
return
}
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
const output = resolve(args.output ?? `${defaultName}.${ext}`)
if (format === 'SVG') {
const nodeIds = args.node ? [args.node] : page.childIds
const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
if (!svgStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
await writeAndLog(output, svgStr)
return
}
let data: Uint8Array | null
if (args.thumbnail) {
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
} else {
const nodeIds = args.node ? [args.node] : page.childIds
data = await exportNodes(graph, page.id, nodeIds, {
scale: Number(args.scale),
format: format as ExportFormat,
quality: args.quality ? Number(args.quality) : undefined
})
}
if (!data) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) }
await writeAndLog(output, data)
}
export default defineCommand({
meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
args: {
file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false },
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)', required: false },
format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
page: { type: 'string', description: 'Page name (default: first page)' },
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)', required: false },
page: { type: 'string', description: 'Page name (default: first page)', required: false },
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)', required: false },
style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
@ -40,114 +133,9 @@ export default defineCommand({
}
if (isAppMode(args.file)) {
if (format === 'SVG') {
const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } })
if (!result.svg) {
printError('Nothing to export.')
process.exit(1)
}
const output = resolve(args.output ?? 'export.svg')
await Bun.write(output, result.svg)
console.log(ok(`Exported ${output} (${(result.svg.length / 1024).toFixed(1)} KB)`))
return
}
if (format === 'JSX') {
const result = await rpc<{ jsx: string }>('export_jsx', {
nodeIds: args.node ? [args.node] : undefined,
style: args.style
})
if (!result.jsx) {
printError('Nothing to export.')
process.exit(1)
}
const output = resolve(args.output ?? 'export.jsx')
await Bun.write(output, result.jsx)
console.log(ok(`Exported ${output} (${(result.jsx.length / 1024).toFixed(1)} KB)`))
return
}
const result = await rpc<{ base64: string }>('export', {
nodeIds: args.node ? [args.node] : undefined,
scale: Number(args.scale),
format: format.toLowerCase()
})
const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
const output = resolve(args.output ?? `export.${ext}`)
await Bun.write(output, data)
console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
return
}
const file = requireFile(args.file)
const graph = await loadDocument(file)
await loadFonts(graph)
const pages = graph.getPages()
const page = args.page
? pages.find((p) => p.name === args.page)
: pages[0]
if (!page) {
printError(`Page "${args.page}" not found.`)
process.exit(1)
}
const defaultName = basename(file, extname(file))
if (format === 'JSX') {
const jsxFormat = args.style as JSXFormat
const nodeIds = args.node ? [args.node] : page.childIds
const jsxStr = nodeIds.length === 1
? sceneNodeToJSX(nodeIds[0], graph, jsxFormat)
: selectionToJSX(nodeIds, graph, jsxFormat)
if (!jsxStr) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
const output = resolve(args.output ?? `${defaultName}.jsx`)
await Bun.write(output, jsxStr)
console.log(ok(`Exported ${output} (${(jsxStr.length / 1024).toFixed(1)} KB)`))
return
}
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
const output = resolve(args.output ?? `${defaultName}.${ext}`)
if (format === 'SVG') {
const nodeIds = args.node ? [args.node] : page.childIds
const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
if (!svgStr) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
await Bun.write(output, svgStr)
console.log(ok(`Exported ${output} (${(svgStr.length / 1024).toFixed(1)} KB)`))
return
}
let data: Uint8Array | null
if (args.thumbnail) {
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
await exportViaApp(format, args)
} else {
const nodeIds = args.node ? [args.node] : page.childIds
data = await exportNodes(graph, page.id, nodeIds, {
scale: Number(args.scale),
format,
quality: args.quality ? Number(args.quality) : undefined
})
await exportFromFile(format, args)
}
if (!data) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
await Bun.write(output, data)
console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`))
}
})

View file

@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
import { loadDocument } from '../headless'
import { isAppMode, requireFile, rpc } from '../app-client'
import { fmtList, printError, bold, entity, formatType } from '../format'
import { fmtList, bold, entity, formatType } from '../format'
import { executeRpcCommand } from '@open-pencil/core'
import type { FindNodeResult } from '@open-pencil/core'

View file

@ -5,7 +5,7 @@ import { isAppMode, requireFile, rpc } from '../app-client'
import { fmtNode, printError, formatType } from '../format'
import { executeRpcCommand, colorToHex } from '@open-pencil/core'
import type { NodeResult } from '@open-pencil/core'
import type { Color, NodeResult } from '@open-pencil/core'
async function getData(file: string | undefined, id: string): Promise<NodeResult | { error: string }> {
if (isAppMode(file)) return rpc<NodeResult>('node', { id })
@ -47,7 +47,7 @@ export default defineCommand({
if (data.parent) details.parent = `${data.parent.name} (${data.parent.id})`
if (data.text) details.text = data.text
if (data.fills.length > 0) {
const solid = (data.fills as Array<{ type: string; visible: boolean; color: { r: number; g: number; b: number; a: number }; opacity: number }>)
const solid = (data.fills as Array<{ type: string; visible: boolean; color: Color; opacity: number }>)
.find((f) => f.type === 'SOLID' && f.visible)
if (solid) {
const hex = colorToHex(solid.color)

View file

@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
import { loadDocument } from '../headless'
import { isAppMode, requireFile, rpc } from '../app-client'
import { bold, fmtList, entity, formatType } from '../format'
import { bold, fmtList, entity } from '../format'
import type { PageItem } from '@open-pencil/core'
import { executeRpcCommand } from '@open-pencil/core'

View file

@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
import { loadDocument } from '../headless'
import { isAppMode, requireFile, rpc } from '../app-client'
import { bold, fmtList, fmtSummary } from '../format'
import { bold, entity, fmtList, fmtSummary } from '../format'
import { executeRpcCommand } from '@open-pencil/core'
import type { VariablesResult } from '@open-pencil/core'
@ -38,7 +38,7 @@ export default defineCommand({
console.log('')
for (const coll of data.collections) {
console.log(bold(` ${coll.name}`) + ` (${coll.modes.join(', ')})`)
console.log(bold(entity(coll.name, coll.modes.join(', '))))
console.log('')
console.log(
fmtList(

View file

@ -48,7 +48,7 @@ export function formatBox(node: SceneNode): string {
function formatFill(node: SceneNode): string | null {
if (!node.fills.length) return null
const solid = node.fills.find((f) => f.type === 'SOLID' && f.visible)
if (!solid || solid.type !== 'SOLID') return null
if (!solid?.color) return null
const { r, g, b } = solid.color
const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
return solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex
@ -56,7 +56,7 @@ function formatFill(node: SceneNode): string | null {
function formatStroke(node: SceneNode): string | null {
if (!node.strokes.length) return null
const s = node.strokes[0]!
const s = node.strokes[0]
const { r, g, b } = s.color
const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
return `${hex} ${s.weight}px`

View file

@ -2,14 +2,14 @@ import CanvasKitInit from 'canvaskit-wasm/full'
import type { CanvasKit } from 'canvaskit-wasm'
import {
parseFigFile,
SceneGraph,
type SceneGraph,
type ExportFormat,
SkiaRenderer,
computeAllLayouts,
loadFont,
renderNodesToImage,
renderThumbnail
} from '@open-pencil/core'
import type { ExportFormat } from '@open-pencil/core'
let ck: CanvasKit | null = null

View file

@ -32,4 +32,4 @@ const main = defineCommand({
}
})
runMain(main)
void runMain(main)