banhaten 0.1.3 → 0.2.0
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/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +44 -0
- package/README.md +65 -13
- package/SECURITY.md +15 -0
- package/banhaten.config.example.json +0 -2
- package/docs/design-system/README.md +9 -0
- package/docs/design-system/adoption-quality-remediation.md +87 -0
- package/docs/design-system/agent-usage.md +13 -0
- package/docs/design-system/appearance-presets.md +6 -1
- package/docs/design-system/component-docs-pattern.md +81 -0
- package/docs/design-system/component-token-consistency-audit.md +13 -3
- package/docs/design-system/components/README.md +3 -2
- package/docs/design-system/components/accordion.md +16 -0
- package/docs/design-system/components/activity-feed.md +15 -1
- package/docs/design-system/components/alert-dialog.md +17 -0
- package/docs/design-system/components/alert.md +46 -14
- package/docs/design-system/components/aspect-ratio.md +15 -0
- package/docs/design-system/components/attribute.md +16 -0
- package/docs/design-system/components/autocomplete.md +19 -0
- package/docs/design-system/components/avatar.md +18 -0
- package/docs/design-system/components/badge.md +19 -0
- package/docs/design-system/components/banner.md +15 -1
- package/docs/design-system/components/breadcrumbs.md +14 -0
- package/docs/design-system/components/button-group.md +34 -0
- package/docs/design-system/components/button.md +18 -0
- package/docs/design-system/components/card.md +18 -1
- package/docs/design-system/components/carousel.md +20 -3
- package/docs/design-system/components/catalog-components.md +15 -1
- package/docs/design-system/components/chart.md +15 -0
- package/docs/design-system/components/checkbox.md +16 -0
- package/docs/design-system/components/collapsible.md +15 -0
- package/docs/design-system/components/command.md +15 -0
- package/docs/design-system/components/context-menu.md +15 -0
- package/docs/design-system/components/date-picker.md +21 -1
- package/docs/design-system/components/divider.md +16 -0
- package/docs/design-system/components/empty-state.md +17 -0
- package/docs/design-system/components/field.md +17 -0
- package/docs/design-system/components/file-upload.md +14 -0
- package/docs/design-system/components/hover-card.md +15 -0
- package/docs/design-system/components/input-group.md +17 -0
- package/docs/design-system/components/input-otp.md +18 -0
- package/docs/design-system/components/input.md +43 -7
- package/docs/design-system/components/item.md +72 -0
- package/docs/design-system/components/kbd.md +18 -0
- package/docs/design-system/components/label.md +16 -0
- package/docs/design-system/components/menu.md +17 -0
- package/docs/design-system/components/menubar.md +15 -0
- package/docs/design-system/components/modal.md +25 -4
- package/docs/design-system/components/native-select.md +18 -0
- package/docs/design-system/components/navigation-menu.md +15 -0
- package/docs/design-system/components/onboarding-step-list-item.md +15 -1
- package/docs/design-system/components/page-header.md +15 -1
- package/docs/design-system/components/pagination.md +30 -8
- package/docs/design-system/components/popover.md +19 -0
- package/docs/design-system/components/progress-slider.md +18 -0
- package/docs/design-system/components/progress.md +17 -0
- package/docs/design-system/components/radio-card.md +16 -0
- package/docs/design-system/components/radio-group.md +17 -0
- package/docs/design-system/components/resizable.md +15 -0
- package/docs/design-system/components/scroll-area.md +15 -0
- package/docs/design-system/components/segmented-control.md +71 -0
- package/docs/design-system/components/select.md +44 -7
- package/docs/design-system/components/sheet.md +17 -0
- package/docs/design-system/components/sidebar.md +15 -0
- package/docs/design-system/components/skeleton.md +18 -0
- package/docs/design-system/components/slideout.md +14 -0
- package/docs/design-system/components/slider.md +20 -0
- package/docs/design-system/components/social-button.md +21 -3
- package/docs/design-system/components/spinner.md +16 -0
- package/docs/design-system/components/steps.md +14 -0
- package/docs/design-system/components/table.md +15 -1
- package/docs/design-system/components/tabs.md +17 -0
- package/docs/design-system/components/tag.md +17 -0
- package/docs/design-system/components/textarea.md +16 -0
- package/docs/design-system/components/timeline.md +14 -0
- package/docs/design-system/components/toast.md +17 -0
- package/docs/design-system/components/toggle.md +17 -0
- package/docs/design-system/components/toolbar.md +21 -1
- package/docs/design-system/components/tooltip.md +18 -1
- package/docs/design-system/density-and-elevation.md +44 -0
- package/docs/design-system/direction-and-localization.md +23 -0
- package/docs/design-system/form-recipes.md +73 -0
- package/docs/design-system/gap-promotion-policy.md +21 -0
- package/docs/design-system/gap-promotion-report.md +166 -0
- package/docs/design-system/installation.md +68 -0
- package/docs/design-system/launch-comparison-shadcn.md +98 -0
- package/docs/design-system/registry-and-cli.md +30 -7
- package/docs/design-system/release-policy.md +30 -0
- package/docs/design-system/roadmap.md +14 -0
- package/docs/design-system/support-matrix.md +23 -0
- package/docs/design-system/tabs.md +1 -1
- package/docs/design-system/token-governance.md +23 -1
- package/package.json +29 -15
- package/registry/component-contract.json +40 -0
- package/registry/components/alert-dialog.tsx +1 -1
- package/registry/components/alert.tsx +86 -65
- package/registry/components/autocomplete.tsx +23 -9
- package/registry/components/avatar.tsx +7 -7
- package/registry/components/badge.tsx +1 -1
- package/registry/components/button-group.tsx +321 -4
- package/registry/components/button.tsx +38 -17
- package/registry/components/card.tsx +68 -59
- package/registry/components/carousel.tsx +53 -5
- package/registry/components/chart.tsx +36 -3
- package/registry/components/checkbox.tsx +3 -3
- package/registry/components/collapsible.tsx +1 -1
- package/registry/components/command.tsx +1 -1
- package/registry/components/context-menu.tsx +1 -1
- package/registry/components/date-picker.tsx +245 -104
- package/registry/components/expanded/ActivityFeed.tsx +6 -15
- package/registry/components/expanded/Banner.tsx +3 -3
- package/registry/components/expanded/Breadcrumbs.tsx +5 -5
- package/registry/components/expanded/CatalogComponentsShowcase.tsx +13 -13
- package/registry/components/expanded/CatalogDivider.tsx +1 -1
- package/registry/components/expanded/EmptyState.tsx +1 -1
- package/registry/components/expanded/FileUpload.tsx +9 -2
- package/registry/components/expanded/OnboardingStepListItem.tsx +2 -4
- package/registry/components/expanded/PageHeader.tsx +13 -13
- package/registry/components/expanded/Slideout.tsx +15 -415
- package/registry/components/expanded/Steps.tsx +4 -4
- package/registry/components/expanded/Table.tsx +116 -69
- package/registry/components/expanded/Timeline.tsx +3 -3
- package/registry/components/expanded/activityFeed.css +1 -1
- package/registry/components/expanded/breadcrumbs.css +1 -1
- package/registry/components/expanded/divider.css +1 -1
- package/registry/components/expanded/emptyState.css +1 -1
- package/registry/components/expanded/fileUpload.css +6 -6
- package/registry/components/expanded/slideout.css +9 -685
- package/registry/components/expanded/steps.css +3 -3
- package/registry/components/expanded/table.css +2 -2
- package/registry/components/expanded/timeline.css +2 -2
- package/registry/components/input-content.ts +1 -0
- package/registry/components/input-otp.tsx +13 -3
- package/registry/components/input.tsx +68 -43
- package/registry/components/item.tsx +189 -0
- package/registry/components/kbd.tsx +111 -4
- package/registry/components/menu.tsx +31 -245
- package/registry/components/menubar.tsx +1 -1
- package/registry/components/modal.tsx +142 -102
- package/registry/components/native-select.tsx +2 -2
- package/registry/components/pagination.tsx +80 -58
- package/registry/components/popover.tsx +162 -664
- package/registry/components/radio-card.tsx +116 -87
- package/registry/components/segmented-control.tsx +87 -0
- package/registry/components/select-content.tsx +2 -2
- package/registry/components/select.tsx +52 -16
- package/registry/components/sheet.tsx +1 -1
- package/registry/components/sidebar.tsx +1 -1
- package/registry/components/skeleton.css +0 -3
- package/registry/components/social-button.tsx +47 -25
- package/registry/components/spinner.tsx +18 -55
- package/registry/components/tabs.css +1 -1
- package/registry/components/tabs.tsx +8 -4
- package/registry/components/tag.tsx +2 -2
- package/registry/components/textarea.tsx +3 -3
- package/registry/components/toast.tsx +16 -11
- package/registry/components/toggle.tsx +2 -2
- package/registry/components/toolbar.tsx +89 -17
- package/registry/components/tooltip.tsx +1 -1
- package/registry/examples/alert-demo.tsx +25 -5
- package/registry/examples/attribute-demo.tsx +1 -1
- package/registry/examples/autocomplete-demo.tsx +3 -3
- package/registry/examples/avatar-demo.tsx +7 -7
- package/registry/examples/badge-demo.tsx +1 -1
- package/registry/examples/button-demo.tsx +6 -6
- package/registry/examples/button-group-demo.tsx +23 -2
- package/registry/examples/card-demo.tsx +46 -8
- package/registry/examples/carousel-demo.tsx +20 -2
- package/registry/examples/chart-demo.tsx +1 -1
- package/registry/examples/checkbox-demo.tsx +5 -5
- package/registry/examples/collection-filter-bar-demo.tsx +135 -0
- package/registry/examples/command-demo.tsx +3 -3
- package/registry/examples/{expanded/command-bar-demo.tsx → command-palette-demo.tsx} +5 -15
- package/registry/examples/context-menu-demo.tsx +1 -1
- package/registry/examples/control-density-contract-demo.tsx +61 -0
- package/registry/examples/date-picker-demo.tsx +12 -12
- package/registry/examples/divider-demo.tsx +3 -3
- package/registry/examples/expanded/banner-demo.tsx +1 -1
- package/registry/examples/expanded/breadcrumbs-demo.tsx +1 -1
- package/registry/examples/expanded/empty-state-demo.tsx +1 -1
- package/registry/examples/expanded/file-upload-demo.tsx +1 -1
- package/registry/examples/expanded/onboarding-step-demo.tsx +1 -1
- package/registry/examples/expanded/slideout-demo.tsx +2 -2
- package/registry/examples/expanded/steps-demo.tsx +1 -1
- package/registry/examples/expanded/tabs-demo.tsx +1 -1
- package/registry/examples/expanded/timeline-demo.tsx +1 -1
- package/registry/examples/hover-card-demo.tsx +2 -2
- package/registry/examples/input-demo.tsx +6 -6
- package/registry/examples/input-otp-demo.tsx +4 -4
- package/registry/examples/item-demo.tsx +46 -0
- package/registry/examples/kbd-demo.tsx +1 -1
- package/registry/examples/label-demo.tsx +1 -1
- package/registry/examples/menu-demo.tsx +1 -1
- package/registry/examples/modal-demo.tsx +67 -9
- package/registry/examples/navigation-menu-demo.tsx +1 -1
- package/registry/examples/pagination-demo.tsx +6 -3
- package/registry/examples/popover-demo.tsx +19 -19
- package/registry/examples/progress-demo.tsx +1 -1
- package/registry/examples/progress-slider-demo.tsx +1 -1
- package/registry/examples/radio-card-demo.tsx +4 -4
- package/registry/examples/radio-group-demo.tsx +2 -2
- package/registry/examples/resizable-demo.tsx +1 -1
- package/registry/examples/results-toolbar-demo.tsx +44 -0
- package/registry/examples/scroll-area-demo.tsx +3 -3
- package/registry/examples/segmented-control-demo.tsx +39 -0
- package/registry/examples/select-demo.tsx +1 -1
- package/registry/examples/sidebar-demo.tsx +1 -1
- package/registry/examples/slider-demo.tsx +3 -3
- package/registry/examples/social-button-demo.tsx +18 -8
- package/registry/examples/spinner-demo.tsx +13 -13
- package/registry/examples/tabs-demo.tsx +1 -1
- package/registry/examples/tag-demo.tsx +1 -1
- package/registry/examples/textarea-demo.tsx +4 -4
- package/registry/examples/toast-demo.tsx +1 -1
- package/registry/examples/toggle-demo.tsx +6 -6
- package/registry/examples/tooltip-demo.tsx +4 -4
- package/registry/guidance/USAGE.md +27 -0
- package/registry/hooks/use-direction.ts +193 -13
- package/registry/index.json +5311 -377
- package/registry/styles/globals.css +2440 -4061
- package/registry.json +119 -149
- package/schema/config.schema.json +11 -8
- package/schema/registry.schema.json +150 -45
- package/schema/tokens.schema.json +134 -34
- package/src/cli/command-line.js +194 -0
- package/src/cli/config-editors.js +9 -0
- package/src/cli/doctor.js +399 -0
- package/src/cli/file-transaction.js +295 -0
- package/src/cli/global-css.js +153 -0
- package/src/cli/index.js +567 -1613
- package/src/cli/install-plan.js +592 -0
- package/src/cli/install-receipt.js +171 -0
- package/src/cli/jsonc.js +203 -0
- package/src/cli/package-install-state.js +254 -0
- package/src/cli/package-manager.js +238 -0
- package/src/cli/package-plan.js +185 -0
- package/src/cli/package-version.js +97 -0
- package/src/cli/registry-query.js +207 -0
- package/src/cli/reporting.js +55 -0
- package/src/cli/schema-validation.js +19 -0
- package/src/cli/source-scanner.js +686 -0
- package/src/cli/vite-config.js +478 -0
- package/tokens/banhaten.tokens.json +4312 -309
- package/docs/design-system/components/command-bar.md +0 -57
- package/registry/components/expanded/BannerBoard.tsx +0 -225
- package/registry/components/expanded/CatalogTag.tsx +0 -85
- package/registry/components/expanded/CommandBar.tsx +0 -386
- package/registry/components/expanded/IconExplorer.tsx +0 -612
- package/registry/components/expanded/TimelineShowcase.tsx +0 -158
- package/registry/components/expanded/commandBar.css +0 -470
- package/registry/components/expanded/iconExplorer.css +0 -764
- package/registry/components/expanded/iconPacks.ts +0 -866
- package/registry/components/expanded/tag.css +0 -148
- package/registry/examples/expanded/icons-demo.tsx +0 -5
- package/tokens/Color mode.zip +0 -0
- package/tokens/Numbers.zip +0 -0
- package/tokens/Radius.zip +0 -0
- package/tokens/Theme.zip +0 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import fs from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
const RECEIPT_FILE = ".banhaten/installed.json"
|
|
6
|
+
const GUIDANCE_FILE = ".banhaten/USAGE.md"
|
|
7
|
+
const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"])
|
|
8
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
".banhaten",
|
|
10
|
+
".git",
|
|
11
|
+
".next",
|
|
12
|
+
".nuxt",
|
|
13
|
+
".output",
|
|
14
|
+
".turbo",
|
|
15
|
+
".vercel",
|
|
16
|
+
"build",
|
|
17
|
+
"coverage",
|
|
18
|
+
"dist",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"out",
|
|
21
|
+
])
|
|
22
|
+
const BANHATEN_CONTROL_TAG =
|
|
23
|
+
/<(Input|Select|DatePicker|DateRangePicker|Button|ButtonGroup|ToggleGroup|SegmentedControl|ToolbarSearch|ToolbarSelect|ToolbarButton)(?=\s|\/?>)[\s\S]*?>/g
|
|
24
|
+
const GEOMETRY_ATTRIBUTE =
|
|
25
|
+
/(?:className|controlClassName|inputClassName|surfaceClassName|triggerClassName)\s*=\s*(?:["']([^"']*)["']|\{\s*["']([^"']*)["']\s*\})/g
|
|
26
|
+
const RAW_GEOMETRY_CLASS =
|
|
27
|
+
/(?:^|\s)(?:h-|min-h-|max-h-|p(?:x|y|s|e|t|r|b|l)?-|rounded(?:-|$)|text-\[)/
|
|
28
|
+
|
|
29
|
+
export async function inspectBanhatenProject({ cwd, registryVersion }) {
|
|
30
|
+
const projectRoot = path.resolve(cwd)
|
|
31
|
+
const diagnostics = []
|
|
32
|
+
const receiptPath = path.join(projectRoot, RECEIPT_FILE)
|
|
33
|
+
const guidancePath = path.join(projectRoot, GUIDANCE_FILE)
|
|
34
|
+
const receipt = await readJsonIfExists(receiptPath, diagnostics, projectRoot)
|
|
35
|
+
|
|
36
|
+
if (!receipt) {
|
|
37
|
+
diagnostics.push(
|
|
38
|
+
diagnostic({
|
|
39
|
+
code: "setup-not-initialized",
|
|
40
|
+
message: "No Banhaten install receipt was found.",
|
|
41
|
+
recommendation: "Run `banhaten init` before installing components.",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
} else {
|
|
46
|
+
inspectReceiptVersion(receipt, registryVersion, diagnostics)
|
|
47
|
+
await inspectManagedFiles(projectRoot, receipt, diagnostics)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!(await exists(guidancePath))) {
|
|
51
|
+
diagnostics.push(
|
|
52
|
+
diagnostic({
|
|
53
|
+
code: "agent-guidance-missing",
|
|
54
|
+
file: GUIDANCE_FILE,
|
|
55
|
+
message: "The installed coding-agent usage rules are missing.",
|
|
56
|
+
recommendation: "Run `banhaten init` to install .banhaten/USAGE.md.",
|
|
57
|
+
severity: "warning",
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sourceFiles = await collectSourceFiles(projectRoot)
|
|
63
|
+
for (const filePath of sourceFiles) {
|
|
64
|
+
await inspectSourceFile(projectRoot, filePath, diagnostics)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
diagnostics.sort(compareDiagnostics)
|
|
68
|
+
const summary = diagnostics.reduce(
|
|
69
|
+
(counts, item) => {
|
|
70
|
+
const key =
|
|
71
|
+
item.severity === "error"
|
|
72
|
+
? "errors"
|
|
73
|
+
: item.severity === "warning"
|
|
74
|
+
? "warnings"
|
|
75
|
+
: "info"
|
|
76
|
+
counts[key] += 1
|
|
77
|
+
return counts
|
|
78
|
+
},
|
|
79
|
+
{ errors: 0, info: 0, warnings: 0 }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
cwd: projectRoot,
|
|
84
|
+
registryVersion,
|
|
85
|
+
installedVersion: receipt?.registryVersion ?? null,
|
|
86
|
+
installedComponents: Object.keys(receipt?.components || {}).sort(),
|
|
87
|
+
scannedFiles: sourceFiles.length,
|
|
88
|
+
summary,
|
|
89
|
+
diagnostics,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function printBanhatenDoctor(report, { json = false, silent = false } = {}) {
|
|
94
|
+
if (json) {
|
|
95
|
+
console.log(JSON.stringify(report, null, 2))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (silent) return
|
|
99
|
+
|
|
100
|
+
console.log("Banhaten doctor")
|
|
101
|
+
console.log(`Project: ${report.cwd}`)
|
|
102
|
+
console.log(
|
|
103
|
+
`Registry: ${report.installedVersion ?? "not installed"} -> ${report.registryVersion}`
|
|
104
|
+
)
|
|
105
|
+
console.log(
|
|
106
|
+
`Scanned ${report.scannedFiles} source files and ${report.installedComponents.length} installed components.`
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (report.diagnostics.length === 0) {
|
|
110
|
+
console.log("No design-system adoption issues found.")
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const item of report.diagnostics) {
|
|
115
|
+
const location = item.file
|
|
116
|
+
? ` ${item.file}${item.line ? `:${item.line}` : ""}`
|
|
117
|
+
: ""
|
|
118
|
+
console.log(`${item.severity.toUpperCase()} ${item.code}${location}`)
|
|
119
|
+
console.log(` ${item.message}`)
|
|
120
|
+
console.log(` ${item.recommendation}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(
|
|
124
|
+
`${report.summary.errors} errors, ${report.summary.warnings} warnings, ${report.summary.info} info.`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function inspectReceiptVersion(receipt, registryVersion, diagnostics) {
|
|
129
|
+
if (receipt.schemaVersion !== 1 || typeof receipt.components !== "object") {
|
|
130
|
+
diagnostics.push(
|
|
131
|
+
diagnostic({
|
|
132
|
+
code: "install-receipt-invalid",
|
|
133
|
+
file: RECEIPT_FILE,
|
|
134
|
+
message: "The Banhaten install receipt has an unsupported shape.",
|
|
135
|
+
recommendation: "Re-run an explicit `banhaten add <component> --force` after reviewing local changes.",
|
|
136
|
+
severity: "error",
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (receipt.registryVersion !== registryVersion) {
|
|
143
|
+
diagnostics.push(
|
|
144
|
+
diagnostic({
|
|
145
|
+
code: "registry-version-drift",
|
|
146
|
+
file: RECEIPT_FILE,
|
|
147
|
+
message: `Installed registry version ${receipt.registryVersion ?? "unknown"} differs from CLI version ${registryVersion}.`,
|
|
148
|
+
recommendation: "Run `banhaten diff`, then update reviewed components with `banhaten update`.",
|
|
149
|
+
severity: "warning",
|
|
150
|
+
})
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function inspectManagedFiles(projectRoot, receipt, diagnostics) {
|
|
156
|
+
for (const [target, record] of Object.entries(receipt.managedFiles || {})) {
|
|
157
|
+
const resolved = path.resolve(projectRoot, target)
|
|
158
|
+
const relative = path.relative(projectRoot, resolved)
|
|
159
|
+
|
|
160
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
161
|
+
diagnostics.push(
|
|
162
|
+
diagnostic({
|
|
163
|
+
code: "managed-target-unsafe",
|
|
164
|
+
file: target,
|
|
165
|
+
message: "The install receipt points outside the project.",
|
|
166
|
+
recommendation: "Remove the unsafe receipt entry and re-run `banhaten init`.",
|
|
167
|
+
severity: "error",
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let content
|
|
174
|
+
try {
|
|
175
|
+
content = await fs.readFile(resolved)
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error.code === "ENOENT") {
|
|
178
|
+
diagnostics.push(
|
|
179
|
+
diagnostic({
|
|
180
|
+
code: "managed-file-missing",
|
|
181
|
+
file: normalizePath(relative),
|
|
182
|
+
message: `The ${record.owner || "Banhaten"}-managed file is missing.`,
|
|
183
|
+
recommendation: "Run `banhaten diff`, then restore it with `banhaten update`.",
|
|
184
|
+
severity: "error",
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
throw error
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const actualHash = createHash("sha256").update(content).digest("hex")
|
|
193
|
+
if (record.sha256 && actualHash !== record.sha256) {
|
|
194
|
+
diagnostics.push(
|
|
195
|
+
diagnostic({
|
|
196
|
+
code: "managed-file-modified",
|
|
197
|
+
file: normalizePath(relative),
|
|
198
|
+
message: `The ${record.owner || "Banhaten"}-managed file differs from its install receipt.`,
|
|
199
|
+
recommendation: "Run `banhaten diff` and either keep the intentional extension or update the registry copy after review.",
|
|
200
|
+
severity: "warning",
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function inspectSourceFile(projectRoot, filePath, diagnostics) {
|
|
208
|
+
const source = await fs.readFile(filePath, "utf8")
|
|
209
|
+
if (source.length > 1024 * 1024) return
|
|
210
|
+
const file = normalizePath(path.relative(projectRoot, filePath))
|
|
211
|
+
|
|
212
|
+
for (const match of source.matchAll(BANHATEN_CONTROL_TAG)) {
|
|
213
|
+
const [tagSource, tagName] = match
|
|
214
|
+
const line = getLine(source, match.index)
|
|
215
|
+
|
|
216
|
+
for (const attribute of tagSource.matchAll(GEOMETRY_ATTRIBUTE)) {
|
|
217
|
+
const classes = attribute[1] ?? attribute[2] ?? ""
|
|
218
|
+
if (!RAW_GEOMETRY_CLASS.test(classes)) continue
|
|
219
|
+
diagnostics.push(
|
|
220
|
+
diagnostic({
|
|
221
|
+
code: "raw-control-geometry",
|
|
222
|
+
file,
|
|
223
|
+
line,
|
|
224
|
+
message: `${tagName} overrides control geometry with ${JSON.stringify(classes)}.`,
|
|
225
|
+
recommendation: "Use density for height and component tokens for padding, radius, and typography; keep classes for width and layout only.",
|
|
226
|
+
severity: "warning",
|
|
227
|
+
})
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (tagName === "Input") {
|
|
232
|
+
if (hasTruthyBooleanProp(tagSource, "hasLeadingIcon") &&
|
|
233
|
+
!hasProp(tagSource, "leadingIcon") &&
|
|
234
|
+
!hasProp(tagSource, "showAtSign")) {
|
|
235
|
+
diagnostics.push(
|
|
236
|
+
diagnostic({
|
|
237
|
+
code: "ambiguous-input-leading-icon",
|
|
238
|
+
file,
|
|
239
|
+
line,
|
|
240
|
+
message: "Input uses the deprecated hasLeadingIcon flag without an explicit icon.",
|
|
241
|
+
recommendation: "Pass leadingIcon={<Icon />} or use showAtSign for the literal @ affordance.",
|
|
242
|
+
severity: "warning",
|
|
243
|
+
})
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (hasTruthyBooleanProp(tagSource, "hasInformationIcon") &&
|
|
248
|
+
!hasProp(tagSource, "trailingIcon") &&
|
|
249
|
+
!hasProp(tagSource, "showInfo")) {
|
|
250
|
+
diagnostics.push(
|
|
251
|
+
diagnostic({
|
|
252
|
+
code: "ambiguous-input-information-icon",
|
|
253
|
+
file,
|
|
254
|
+
line,
|
|
255
|
+
message: "Input uses the deprecated hasInformationIcon flag without an explicit icon.",
|
|
256
|
+
recommendation: "Pass trailingIcon={<Icon />} or use showInfo for the literal information affordance.",
|
|
257
|
+
severity: "warning",
|
|
258
|
+
})
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (hasProp(tagSource, "shortcut") && !hasProp(tagSource, "shortcutKeys")) {
|
|
263
|
+
diagnostics.push(
|
|
264
|
+
diagnostic({
|
|
265
|
+
code: "legacy-input-shortcut",
|
|
266
|
+
file,
|
|
267
|
+
line,
|
|
268
|
+
message: "Input uses a single legacy shortcut keycap.",
|
|
269
|
+
recommendation: "Use shortcutKeys={[\"Mod\", \"K\"]} for platform-aware, multi-key rendering.",
|
|
270
|
+
severity: "warning",
|
|
271
|
+
})
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
inspectNativeTags(source, file, diagnostics)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function inspectNativeTags(source, file, diagnostics) {
|
|
281
|
+
for (const match of source.matchAll(/<select(?=\s|>)[\s\S]*?>/g)) {
|
|
282
|
+
diagnostics.push(
|
|
283
|
+
diagnostic({
|
|
284
|
+
code: "native-select-control",
|
|
285
|
+
file,
|
|
286
|
+
line: getLine(source, match.index),
|
|
287
|
+
message: "A native select bypasses the Banhaten Select contract.",
|
|
288
|
+
recommendation: "Use Select for product UI, unless native platform behavior is an explicit requirement.",
|
|
289
|
+
severity: "warning",
|
|
290
|
+
})
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const match of source.matchAll(/<input(?=\s|>)[\s\S]*?>/g)) {
|
|
295
|
+
if (!/\btype\s*=\s*["']date["']/.test(match[0])) continue
|
|
296
|
+
diagnostics.push(
|
|
297
|
+
diagnostic({
|
|
298
|
+
code: "native-date-control",
|
|
299
|
+
file,
|
|
300
|
+
line: getLine(source, match.index),
|
|
301
|
+
message: "A native date input bypasses the Banhaten calendar and density contracts.",
|
|
302
|
+
recommendation: "Use DatePicker or DateRangePicker.",
|
|
303
|
+
severity: "warning",
|
|
304
|
+
})
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function collectSourceFiles(projectRoot) {
|
|
310
|
+
const files = []
|
|
311
|
+
await visit(projectRoot)
|
|
312
|
+
return files.sort()
|
|
313
|
+
|
|
314
|
+
async function visit(directory) {
|
|
315
|
+
const entries = await fs.readdir(directory, { withFileTypes: true })
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) {
|
|
318
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue
|
|
319
|
+
}
|
|
320
|
+
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
|
|
321
|
+
|
|
322
|
+
const target = path.join(directory, entry.name)
|
|
323
|
+
if (entry.isDirectory()) {
|
|
324
|
+
await visit(target)
|
|
325
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
326
|
+
files.push(target)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function hasProp(tagSource, prop) {
|
|
333
|
+
return new RegExp(`\\b${prop}(?:\\s*=|(?=\\s|\\/?>))`).test(tagSource)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hasTruthyBooleanProp(tagSource, prop) {
|
|
337
|
+
return new RegExp(
|
|
338
|
+
`\\b${prop}(?:\\s*=\\s*\\{\\s*true\\s*\\})?(?=\\s|\\/?>)`
|
|
339
|
+
).test(tagSource)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getLine(source, index = 0) {
|
|
343
|
+
return source.slice(0, index).split("\n").length
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function diagnostic({ code, file, line, message, recommendation, severity }) {
|
|
347
|
+
return {
|
|
348
|
+
code,
|
|
349
|
+
severity,
|
|
350
|
+
...(file ? { file } : {}),
|
|
351
|
+
...(line ? { line } : {}),
|
|
352
|
+
message,
|
|
353
|
+
recommendation,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function compareDiagnostics(left, right) {
|
|
358
|
+
const severityOrder = { error: 0, warning: 1, info: 2 }
|
|
359
|
+
return (
|
|
360
|
+
severityOrder[left.severity] - severityOrder[right.severity] ||
|
|
361
|
+
(left.file || "").localeCompare(right.file || "") ||
|
|
362
|
+
(left.line || 0) - (right.line || 0) ||
|
|
363
|
+
left.code.localeCompare(right.code)
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function normalizePath(value) {
|
|
368
|
+
return value.replaceAll("\\", "/")
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function exists(filePath) {
|
|
372
|
+
try {
|
|
373
|
+
await fs.access(filePath)
|
|
374
|
+
return true
|
|
375
|
+
} catch {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function readJsonIfExists(filePath, diagnostics, projectRoot) {
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error.code === "ENOENT") return null
|
|
385
|
+
if (error instanceof SyntaxError) {
|
|
386
|
+
diagnostics.push(
|
|
387
|
+
diagnostic({
|
|
388
|
+
code: "install-receipt-invalid-json",
|
|
389
|
+
file: normalizePath(path.relative(projectRoot, filePath)),
|
|
390
|
+
message: "The Banhaten install receipt is not valid JSON.",
|
|
391
|
+
recommendation: "Review the file, then rebuild it with an explicit `banhaten add <component> --force`.",
|
|
392
|
+
severity: "error",
|
|
393
|
+
})
|
|
394
|
+
)
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
throw error
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import process from "node:process"
|
|
4
|
+
|
|
5
|
+
let activeFileTransaction = null
|
|
6
|
+
let transactionFileCounter = 0
|
|
7
|
+
|
|
8
|
+
export async function runFileTransaction(action) {
|
|
9
|
+
if (activeFileTransaction) {
|
|
10
|
+
throw new Error("nested file transactions are not supported")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const transaction = {
|
|
14
|
+
writes: new Map(),
|
|
15
|
+
preconditions: new Map(),
|
|
16
|
+
afterApply: [],
|
|
17
|
+
}
|
|
18
|
+
activeFileTransaction = transaction
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const result = await action()
|
|
22
|
+
await commitFileTransaction(transaction)
|
|
23
|
+
activeFileTransaction = null
|
|
24
|
+
|
|
25
|
+
for (const callback of transaction.afterApply) {
|
|
26
|
+
await callback()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return result
|
|
30
|
+
} catch (error) {
|
|
31
|
+
activeFileTransaction = null
|
|
32
|
+
throw error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function deferUntilApplied(callback) {
|
|
37
|
+
if (!activeFileTransaction) {
|
|
38
|
+
return callback()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
activeFileTransaction.afterApply.push(callback)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function commitFileTransaction(transaction) {
|
|
45
|
+
const staged = []
|
|
46
|
+
const committed = []
|
|
47
|
+
const createdDirectories = new Set()
|
|
48
|
+
let failed = false
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await assertTransactionPreconditions(transaction)
|
|
52
|
+
|
|
53
|
+
for (const entry of transaction.writes.values()) {
|
|
54
|
+
await ensureDirectory(
|
|
55
|
+
path.dirname(entry.targetPath),
|
|
56
|
+
createdDirectories
|
|
57
|
+
)
|
|
58
|
+
const tempPath = `${entry.targetPath}.banhaten-${process.pid}-${transactionFileCounter += 1}.tmp`
|
|
59
|
+
const previous = await readFileSnapshot(entry.targetPath)
|
|
60
|
+
|
|
61
|
+
await fs.writeFile(tempPath, entry.content)
|
|
62
|
+
if (previous?.mode !== undefined) {
|
|
63
|
+
await fs.chmod(tempPath, previous.mode)
|
|
64
|
+
}
|
|
65
|
+
staged.push({ ...entry, tempPath, previous })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await assertTransactionPreconditions(transaction)
|
|
69
|
+
|
|
70
|
+
for (const entry of staged) {
|
|
71
|
+
await fs.rename(entry.tempPath, entry.targetPath)
|
|
72
|
+
committed.push(entry)
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
failed = true
|
|
76
|
+
for (const entry of [...committed].reverse()) {
|
|
77
|
+
await restoreFileSnapshot(entry.targetPath, entry.previous)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error(`file transaction failed and was rolled back: ${error.message}`)
|
|
81
|
+
} finally {
|
|
82
|
+
await Promise.all(
|
|
83
|
+
staged.map((entry) => fs.rm(entry.tempPath, { force: true }).catch(() => {}))
|
|
84
|
+
)
|
|
85
|
+
if (failed) {
|
|
86
|
+
await removeCreatedDirectories(createdDirectories)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ensureDirectory(targetPath, createdDirectories) {
|
|
92
|
+
const missing = []
|
|
93
|
+
let directory = targetPath
|
|
94
|
+
|
|
95
|
+
while (!(await exists(directory))) {
|
|
96
|
+
missing.push(directory)
|
|
97
|
+
const parent = path.dirname(directory)
|
|
98
|
+
if (parent === directory) break
|
|
99
|
+
directory = parent
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const missingDirectory of missing.reverse()) {
|
|
103
|
+
try {
|
|
104
|
+
await fs.mkdir(missingDirectory)
|
|
105
|
+
createdDirectories.add(missingDirectory)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== "EEXIST") throw error
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function removeCreatedDirectories(createdDirectories) {
|
|
113
|
+
for (const directory of [...createdDirectories].reverse()) {
|
|
114
|
+
await fs.rmdir(directory).catch(() => {})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function assertTransactionPreconditions(transaction) {
|
|
119
|
+
for (const [targetPath, expectedCurrent] of transaction.preconditions) {
|
|
120
|
+
const current = await readFileSnapshot(targetPath)
|
|
121
|
+
assertExpectedCurrent(targetPath, current?.content ?? null, expectedCurrent)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readFileSnapshot(targetPath) {
|
|
126
|
+
try {
|
|
127
|
+
const [content, stats] = await Promise.all([
|
|
128
|
+
fs.readFile(targetPath),
|
|
129
|
+
fs.stat(targetPath),
|
|
130
|
+
])
|
|
131
|
+
return { content, mode: stats.mode }
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code === "ENOENT") return null
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function restoreFileSnapshot(targetPath, snapshot) {
|
|
139
|
+
if (!snapshot) {
|
|
140
|
+
await fs.rm(targetPath, { force: true })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tempPath = `${targetPath}.banhaten-rollback-${process.pid}-${transactionFileCounter += 1}.tmp`
|
|
145
|
+
await fs.writeFile(tempPath, snapshot.content)
|
|
146
|
+
await fs.chmod(tempPath, snapshot.mode)
|
|
147
|
+
await fs.rename(tempPath, targetPath)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function writeFile(targetPath, content, options) {
|
|
151
|
+
const alreadyExists = await exists(targetPath)
|
|
152
|
+
const isBinary = Buffer.isBuffer(content)
|
|
153
|
+
const contentBuffer = isBinary ? content : Buffer.from(content, "utf8")
|
|
154
|
+
const current = alreadyExists ? await fs.readFile(targetPath) : null
|
|
155
|
+
const isEqual = current?.equals(contentBuffer) || false
|
|
156
|
+
if (options.expectedCurrent !== undefined) {
|
|
157
|
+
const expectedCurrent = toExpectedBuffer(options.expectedCurrent)
|
|
158
|
+
assertExpectedCurrent(targetPath, current, expectedCurrent)
|
|
159
|
+
registerPrecondition(targetPath, expectedCurrent)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (options.dryRun) {
|
|
163
|
+
if (!alreadyExists) return "would write"
|
|
164
|
+
if (isEqual) return "would keep"
|
|
165
|
+
|
|
166
|
+
return "would update"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (alreadyExists && isEqual) {
|
|
170
|
+
return "kept"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!activeFileTransaction) {
|
|
174
|
+
throw new Error(`refusing non-transactional write to ${targetPath}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const existingPlan = activeFileTransaction.writes.get(targetPath)
|
|
178
|
+
if (existingPlan && !existingPlan.content.equals(contentBuffer)) {
|
|
179
|
+
throw new Error(`conflicting planned writes for ${targetPath}`)
|
|
180
|
+
}
|
|
181
|
+
activeFileTransaction.writes.set(targetPath, {
|
|
182
|
+
targetPath,
|
|
183
|
+
content: contentBuffer,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return alreadyExists ? "updated" : "wrote"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function assertFileUnchanged(targetPath, expectedCurrent) {
|
|
190
|
+
if (!activeFileTransaction) {
|
|
191
|
+
throw new Error(`refusing non-transactional precondition for ${targetPath}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const expectedBuffer = toExpectedBuffer(expectedCurrent)
|
|
195
|
+
const current = await readFileContent(targetPath)
|
|
196
|
+
assertExpectedCurrent(targetPath, current, expectedBuffer)
|
|
197
|
+
registerPrecondition(targetPath, expectedBuffer)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function registerPrecondition(targetPath, expectedCurrent) {
|
|
201
|
+
if (!activeFileTransaction) {
|
|
202
|
+
throw new Error(`refusing non-transactional precondition for ${targetPath}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const existing = activeFileTransaction.preconditions.get(targetPath)
|
|
206
|
+
if (existing !== undefined) {
|
|
207
|
+
assertExpectedCurrent(targetPath, existing, expectedCurrent)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
activeFileTransaction.preconditions.set(targetPath, expectedCurrent)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function assertExpectedCurrent(targetPath, current, expected) {
|
|
214
|
+
const matches = expected === null
|
|
215
|
+
? current === null
|
|
216
|
+
: current?.equals(expected) || false
|
|
217
|
+
if (!matches) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`${targetPath} changed while Banhaten was preparing the install plan`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function toExpectedBuffer(expectedCurrent) {
|
|
225
|
+
return expectedCurrent === null
|
|
226
|
+
? null
|
|
227
|
+
: Buffer.from(expectedCurrent)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function readFileContent(targetPath) {
|
|
231
|
+
try {
|
|
232
|
+
return await fs.readFile(targetPath)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error.code === "ENOENT") return null
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatUnifiedDiff(fileLabel, currentContent, nextContent) {
|
|
240
|
+
const current = toDiffLines(currentContent)
|
|
241
|
+
const next = toDiffLines(nextContent)
|
|
242
|
+
let prefix = 0
|
|
243
|
+
|
|
244
|
+
while (
|
|
245
|
+
prefix < current.length &&
|
|
246
|
+
prefix < next.length &&
|
|
247
|
+
current[prefix] === next[prefix]
|
|
248
|
+
) {
|
|
249
|
+
prefix += 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let currentSuffix = current.length
|
|
253
|
+
let nextSuffix = next.length
|
|
254
|
+
while (
|
|
255
|
+
currentSuffix > prefix &&
|
|
256
|
+
nextSuffix > prefix &&
|
|
257
|
+
current[currentSuffix - 1] === next[nextSuffix - 1]
|
|
258
|
+
) {
|
|
259
|
+
currentSuffix -= 1
|
|
260
|
+
nextSuffix -= 1
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const context = 3
|
|
264
|
+
const start = Math.max(0, prefix - context)
|
|
265
|
+
const currentEnd = Math.min(current.length, currentSuffix + context)
|
|
266
|
+
const nextEnd = Math.min(next.length, nextSuffix + context)
|
|
267
|
+
const currentCount = currentEnd - start
|
|
268
|
+
const nextCount = nextEnd - start
|
|
269
|
+
const output = [
|
|
270
|
+
`--- a/${fileLabel}`,
|
|
271
|
+
`+++ b/${fileLabel}`,
|
|
272
|
+
`@@ -${start + 1},${currentCount} +${start + 1},${nextCount} @@`,
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
for (const line of current.slice(start, prefix)) output.push(` ${line}`)
|
|
276
|
+
for (const line of current.slice(prefix, currentSuffix)) output.push(`-${line}`)
|
|
277
|
+
for (const line of next.slice(prefix, nextSuffix)) output.push(`+${line}`)
|
|
278
|
+
for (const line of current.slice(currentSuffix, currentEnd)) output.push(` ${line}`)
|
|
279
|
+
|
|
280
|
+
return output.join("\n")
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function toDiffLines(content) {
|
|
284
|
+
if (Buffer.isBuffer(content)) return [`Binary content (${content.byteLength} bytes)`]
|
|
285
|
+
return String(content).replace(/\r\n/g, "\n").split("\n")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function exists(filePath) {
|
|
289
|
+
try {
|
|
290
|
+
await fs.access(filePath)
|
|
291
|
+
return true
|
|
292
|
+
} catch {
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
}
|