cascivo 0.1.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/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/cascivo.mjs +11 -0
- package/dist/audit-AHr4MDMK.mjs +583 -0
- package/dist/audit-AHr4MDMK.mjs.map +1 -0
- package/dist/drift-D7JFzpP_.mjs +8 -0
- package/dist/drift-D7JFzpP_.mjs.map +1 -0
- package/dist/index.d.mts +26 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1438 -0
- package/dist/index.mjs.map +1 -0
- package/dist/tokens-CO1xRiYC.mjs +8 -0
- package/dist/tokens-CO1xRiYC.mjs.map +1 -0
- package/package.json +58 -0
- package/readme.body.md +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 urbanisierung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!-- generated by scripts/readme/generate.ts — edit readme.body.md, not this file -->
|
|
2
|
+
|
|
3
|
+
# cascivo
|
|
4
|
+
|
|
5
|
+
> CLI — npx cascivo init / add / list / update
|
|
6
|
+
|
|
7
|
+
[cascivo.com](https://cascivo.com) · [Docs](https://docs.cascivo.com) · [Storybook](https://storybook.cascivo.com) · [GitHub](https://github.com/urbanisierung/cascivo)
|
|
8
|
+
|
|
9
|
+
CLI for scaffold and copy-paste workflows — `npx cascivo init`, `cascivo add <component>`, `cascivo audit --ai`. Detects your package manager, writes `cascivo.config.ts`, and fetches component source from `registry.json`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add cascivo
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
[cascivo.com](https://cascivo.com) · [Docs](https://docs.cascivo.com) · [Storybook](https://storybook.cascivo.com) · [GitHub](https://github.com/urbanisierung/cascivo) · AI agents: use [`@cascivo/mcp`](https://github.com/urbanisierung/cascivo/tree/main/packages/mcp) and [`registry.json`](https://github.com/urbanisierung/cascivo/blob/main/registry.json) · MIT
|
package/bin/cascivo.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Committed launcher: the package `bin` points here so pnpm can always link the
|
|
3
|
+
// command shim at install time, even before `dist/` is built. The actual CLI is
|
|
4
|
+
// loaded lazily from the built output (produced earlier in the build graph).
|
|
5
|
+
import { argv } from 'node:process'
|
|
6
|
+
import { run } from '../dist/index.mjs'
|
|
7
|
+
|
|
8
|
+
run(argv.slice(2)).catch((error) => {
|
|
9
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
10
|
+
process.exitCode = 1
|
|
11
|
+
})
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, extname, join } from "node:path";
|
|
4
|
+
//#region src/utils/contract-pure.ts
|
|
5
|
+
/** Normalize a color/size value for catalog comparison: lowercase, strip spaces. */
|
|
6
|
+
function normalizeValue(value) {
|
|
7
|
+
return value.toLowerCase().replace(/\s+/g, "");
|
|
8
|
+
}
|
|
9
|
+
/** Pure builder — assemble a Contract from already-parsed JSON. Testable without fs. */
|
|
10
|
+
function buildContract(input) {
|
|
11
|
+
const tokensByValue = /* @__PURE__ */ new Map();
|
|
12
|
+
for (const token of input.catalog.tokens) {
|
|
13
|
+
if (token.resolvedDefault == null) continue;
|
|
14
|
+
const key = normalizeValue(token.resolvedDefault);
|
|
15
|
+
const list = tokensByValue.get(key);
|
|
16
|
+
if (list) list.push(token.name);
|
|
17
|
+
else tokensByValue.set(key, [token.name]);
|
|
18
|
+
}
|
|
19
|
+
const contentNames = /* @__PURE__ */ new Set();
|
|
20
|
+
for (const c of input.context.components) if (c.intent?.content) contentNames.add(c.name);
|
|
21
|
+
const components = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const entry of input.registry.components) {
|
|
23
|
+
const meta = entry.meta;
|
|
24
|
+
if (!meta?.name) continue;
|
|
25
|
+
const props = (meta.props ?? []).map((p) => ({
|
|
26
|
+
name: p.name,
|
|
27
|
+
type: p.type ?? "unknown",
|
|
28
|
+
required: p.required === true
|
|
29
|
+
}));
|
|
30
|
+
const requiredProps = props.filter((p) => p.required).map((p) => p.name);
|
|
31
|
+
components.set(meta.name, {
|
|
32
|
+
props,
|
|
33
|
+
requiredProps,
|
|
34
|
+
hasRequiredProps: requiredProps.length > 0,
|
|
35
|
+
hasContent: contentNames.has(meta.name)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
tokensByValue,
|
|
40
|
+
components
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/audit-ai/css-literals.ts
|
|
45
|
+
/** Visual CSS properties whose literal values should be tokens. */
|
|
46
|
+
const VISUAL_PROPS = new Set([
|
|
47
|
+
"color",
|
|
48
|
+
"background",
|
|
49
|
+
"background-color",
|
|
50
|
+
"border-color",
|
|
51
|
+
"box-shadow",
|
|
52
|
+
"border-radius",
|
|
53
|
+
"font-size",
|
|
54
|
+
"gap",
|
|
55
|
+
"padding",
|
|
56
|
+
"margin",
|
|
57
|
+
"width",
|
|
58
|
+
"height"
|
|
59
|
+
]);
|
|
60
|
+
/** Inline-style camelCase → kebab-case for the props we care about. */
|
|
61
|
+
const INLINE_PROP_MAP = {
|
|
62
|
+
color: "color",
|
|
63
|
+
background: "background",
|
|
64
|
+
backgroundColor: "background-color",
|
|
65
|
+
borderColor: "border-color",
|
|
66
|
+
boxShadow: "box-shadow",
|
|
67
|
+
borderRadius: "border-radius",
|
|
68
|
+
fontSize: "font-size",
|
|
69
|
+
gap: "gap",
|
|
70
|
+
padding: "padding",
|
|
71
|
+
margin: "margin",
|
|
72
|
+
width: "width",
|
|
73
|
+
height: "height"
|
|
74
|
+
};
|
|
75
|
+
/** A literal value worth checking: hex, oklch/rgb/hsl(a), or px/rem number. */
|
|
76
|
+
function isLiteralValue(value) {
|
|
77
|
+
const v = value.trim();
|
|
78
|
+
if (v.includes("var(")) return false;
|
|
79
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) return true;
|
|
80
|
+
if (/^(oklch|oklab|rgb|rgba|hsl|hsla)\(/i.test(v)) return true;
|
|
81
|
+
if (/^-?\d*\.?\d+(px|rem)$/.test(v)) return true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
function classify(value, contract) {
|
|
85
|
+
const matches = contract.tokensByValue.get(normalizeValue(value));
|
|
86
|
+
if (!matches || matches.length === 0) return null;
|
|
87
|
+
const first = matches[0];
|
|
88
|
+
if (matches.length === 1 && first) return {
|
|
89
|
+
level: "error",
|
|
90
|
+
suggestedToken: first
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
level: "info",
|
|
94
|
+
allMatches: matches
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Detect literal color/size values in CSS declarations and TSX inline styles
|
|
99
|
+
* that exactly match a known cascade token. Heuristic, line-based — no full
|
|
100
|
+
* CSS/JS parse. Values with no catalog match are NOT flagged (arbitrary brand
|
|
101
|
+
* values are allowed).
|
|
102
|
+
*/
|
|
103
|
+
function findCssLiteralViolations(source, filename, contract) {
|
|
104
|
+
const findings = [];
|
|
105
|
+
const lines = source.split("\n");
|
|
106
|
+
const cssDecl = /(^|[;{\s])([a-z-]+)\s*:\s*([^;}{]+?)\s*(?=[;}]|$)/gi;
|
|
107
|
+
const inlineDecl = /([A-Za-z][A-Za-z]*)\s*:\s*(['"])([^'"]+)\2/g;
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const line = lines[i];
|
|
110
|
+
if (line === void 0) continue;
|
|
111
|
+
const seen = /* @__PURE__ */ new Set();
|
|
112
|
+
for (const m of line.matchAll(cssDecl)) {
|
|
113
|
+
if (m[2] === void 0 || m[3] === void 0) continue;
|
|
114
|
+
const prop = m[2].toLowerCase();
|
|
115
|
+
const rawValue = m[3].trim();
|
|
116
|
+
if (!VISUAL_PROPS.has(prop)) continue;
|
|
117
|
+
if (!isLiteralValue(rawValue)) continue;
|
|
118
|
+
const cls = classify(rawValue, contract);
|
|
119
|
+
if (!cls) continue;
|
|
120
|
+
const key = `${prop}|${rawValue}`;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
findings.push({
|
|
123
|
+
file: filename,
|
|
124
|
+
line: i + 1,
|
|
125
|
+
property: prop,
|
|
126
|
+
value: rawValue,
|
|
127
|
+
rule: "hardcoded-value",
|
|
128
|
+
...cls
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
for (const m of line.matchAll(inlineDecl)) {
|
|
132
|
+
if (m[1] === void 0 || m[3] === void 0) continue;
|
|
133
|
+
const prop = INLINE_PROP_MAP[m[1]];
|
|
134
|
+
if (!prop) continue;
|
|
135
|
+
const rawValue = m[3].trim();
|
|
136
|
+
if (!isLiteralValue(rawValue)) continue;
|
|
137
|
+
const key = `${prop}|${rawValue}`;
|
|
138
|
+
if (seen.has(key)) continue;
|
|
139
|
+
const cls = classify(rawValue, contract);
|
|
140
|
+
if (!cls) continue;
|
|
141
|
+
findings.push({
|
|
142
|
+
file: filename,
|
|
143
|
+
line: i + 1,
|
|
144
|
+
property: prop,
|
|
145
|
+
value: rawValue,
|
|
146
|
+
rule: "hardcoded-value",
|
|
147
|
+
...cls
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return findings;
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/audit-ai/jsx-props.ts
|
|
155
|
+
/** Props always allowed on any cascade component (DOM passthrough / React intrinsics). */
|
|
156
|
+
const PASSTHROUGH = new Set([
|
|
157
|
+
"className",
|
|
158
|
+
"style",
|
|
159
|
+
"id",
|
|
160
|
+
"ref",
|
|
161
|
+
"key",
|
|
162
|
+
"children"
|
|
163
|
+
]);
|
|
164
|
+
function isPassthrough(prop) {
|
|
165
|
+
if (PASSTHROUGH.has(prop)) return true;
|
|
166
|
+
if (prop.startsWith("data-")) return true;
|
|
167
|
+
if (prop.startsWith("aria-")) return true;
|
|
168
|
+
if (/^on[A-Z]/.test(prop)) return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/** Names imported from @cascivo/react in this source. */
|
|
172
|
+
function importedCascadeComponents(source) {
|
|
173
|
+
const names = /* @__PURE__ */ new Set();
|
|
174
|
+
for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]@cascivo\/react['"]/g)) {
|
|
175
|
+
const group = m[1];
|
|
176
|
+
if (group === void 0) continue;
|
|
177
|
+
for (const raw of group.split(",")) {
|
|
178
|
+
const name = raw.trim().split(/\s+as\s+/)[0]?.trim();
|
|
179
|
+
if (name) names.add(name);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return names;
|
|
183
|
+
}
|
|
184
|
+
function findOpeningTags(source, comp) {
|
|
185
|
+
const tags = [];
|
|
186
|
+
const re = new RegExp(`<${comp}(?=[\\s/>])`, "g");
|
|
187
|
+
for (const m of source.matchAll(re)) {
|
|
188
|
+
const start = m.index ?? 0;
|
|
189
|
+
let i = start + m[0].length;
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let quote = "";
|
|
192
|
+
let attrs = "";
|
|
193
|
+
let closed = false;
|
|
194
|
+
for (; i < source.length; i++) {
|
|
195
|
+
const ch = source[i];
|
|
196
|
+
if (quote) {
|
|
197
|
+
if (ch === quote) quote = "";
|
|
198
|
+
attrs += ch;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
202
|
+
quote = ch;
|
|
203
|
+
attrs += ch;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (ch === "{") depth++;
|
|
207
|
+
else if (ch === "}") depth--;
|
|
208
|
+
else if (ch === ">" && depth === 0) {
|
|
209
|
+
closed = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
attrs += ch;
|
|
213
|
+
}
|
|
214
|
+
if (!closed) continue;
|
|
215
|
+
const cleanAttrs = attrs.replace(/\/$/, "");
|
|
216
|
+
tags.push({
|
|
217
|
+
attrs: cleanAttrs,
|
|
218
|
+
index: start,
|
|
219
|
+
hasSpread: /\{\s*\.\.\./.test(cleanAttrs)
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return tags;
|
|
223
|
+
}
|
|
224
|
+
/** Extract top-level attribute names from an opening-tag attribute string. */
|
|
225
|
+
function extractAttrNames(attrs) {
|
|
226
|
+
const names = [];
|
|
227
|
+
let depth = 0;
|
|
228
|
+
let quote = "";
|
|
229
|
+
let token = "";
|
|
230
|
+
const flush = () => {
|
|
231
|
+
const name = token.trim().split("=")[0]?.trim();
|
|
232
|
+
if (name && /^[A-Za-z]/.test(name)) names.push(name);
|
|
233
|
+
token = "";
|
|
234
|
+
};
|
|
235
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
236
|
+
const ch = attrs[i];
|
|
237
|
+
if (ch === void 0) continue;
|
|
238
|
+
if (quote) {
|
|
239
|
+
if (ch === quote) quote = "";
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
243
|
+
quote = ch;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === "{") {
|
|
247
|
+
depth++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (ch === "}") {
|
|
251
|
+
depth--;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (depth > 0) continue;
|
|
255
|
+
if (ch === "=") {
|
|
256
|
+
flush();
|
|
257
|
+
token = "";
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (/\s/.test(ch)) {
|
|
261
|
+
if (token.trim()) flush();
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
token += ch;
|
|
265
|
+
}
|
|
266
|
+
if (token.trim()) flush();
|
|
267
|
+
return names;
|
|
268
|
+
}
|
|
269
|
+
function lineOf(source, index) {
|
|
270
|
+
let line = 1;
|
|
271
|
+
for (let i = 0; i < index && i < source.length; i++) if (source[i] === "\n") line++;
|
|
272
|
+
return line;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Check JSX usages of imported cascade components for unknown props.
|
|
276
|
+
* Heuristic — regex/brace-aware scan, not a full AST. Elements using a spread
|
|
277
|
+
* (`{...rest}`) are reported as info and skipped (props can't be statically known).
|
|
278
|
+
*/
|
|
279
|
+
function findJsxPropViolations(source, filename, contract) {
|
|
280
|
+
const findings = [];
|
|
281
|
+
const tracked = importedCascadeComponents(source);
|
|
282
|
+
for (const comp of tracked) {
|
|
283
|
+
const info = contract.components.get(comp);
|
|
284
|
+
if (!info) continue;
|
|
285
|
+
const known = new Set(info.props.map((p) => p.name));
|
|
286
|
+
for (const tag of findOpeningTags(source, comp)) {
|
|
287
|
+
const line = lineOf(source, tag.index);
|
|
288
|
+
if (tag.hasSpread) {
|
|
289
|
+
findings.push({
|
|
290
|
+
file: filename,
|
|
291
|
+
line,
|
|
292
|
+
component: comp,
|
|
293
|
+
prop: "...",
|
|
294
|
+
level: "info",
|
|
295
|
+
rule: "spread-suppressed",
|
|
296
|
+
message: `<${comp}> uses a spread — prop checks skipped`
|
|
297
|
+
});
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const name of extractAttrNames(tag.attrs)) {
|
|
301
|
+
if (isPassthrough(name)) continue;
|
|
302
|
+
if (known.has(name)) continue;
|
|
303
|
+
findings.push({
|
|
304
|
+
file: filename,
|
|
305
|
+
line,
|
|
306
|
+
component: comp,
|
|
307
|
+
prop: name,
|
|
308
|
+
level: "error",
|
|
309
|
+
rule: "unknown-prop",
|
|
310
|
+
message: `<${comp}> has unknown prop "${name}"`
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return findings;
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/audit-ai/raw-strings.ts
|
|
319
|
+
/** Looks like English prose worth flagging: ≥2 whitespace-separated words, letters/spaces only. */
|
|
320
|
+
function looksLikeProse(text) {
|
|
321
|
+
const trimmed = text.trim();
|
|
322
|
+
if (!/^[A-Za-z][A-Za-z\s]*$/.test(trimmed)) return false;
|
|
323
|
+
return trimmed.split(/\s+/).length >= 2;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Conservative raw-string check: for cascade components that own user-facing
|
|
327
|
+
* chrome text (intent.content), warn when a literal multi-word English child
|
|
328
|
+
* appears, suggesting the labels prop / i18n. Never errors. Only inspects the
|
|
329
|
+
* immediate text directly after the opening tag (no nested element traversal).
|
|
330
|
+
*/
|
|
331
|
+
function findRawStringViolations(source, filename, contract) {
|
|
332
|
+
const findings = [];
|
|
333
|
+
const tracked = importedCascadeComponents(source);
|
|
334
|
+
for (const comp of tracked) {
|
|
335
|
+
if (!contract.components.get(comp)?.hasContent) continue;
|
|
336
|
+
for (const tag of findOpeningTags(source, comp)) {
|
|
337
|
+
const openEnd = source.indexOf(">", tag.index);
|
|
338
|
+
if (openEnd === -1) continue;
|
|
339
|
+
if (source[openEnd - 1] === "/") continue;
|
|
340
|
+
const after = source.slice(openEnd + 1);
|
|
341
|
+
const stop = after.search(/[<{]/);
|
|
342
|
+
const child = (stop === -1 ? after : after.slice(0, stop)).trim();
|
|
343
|
+
if (!child || !looksLikeProse(child)) continue;
|
|
344
|
+
findings.push({
|
|
345
|
+
file: filename,
|
|
346
|
+
line: lineOf(source, openEnd),
|
|
347
|
+
component: comp,
|
|
348
|
+
text: child,
|
|
349
|
+
level: "warn",
|
|
350
|
+
rule: "raw-string",
|
|
351
|
+
message: `<${comp}> raw text "${child}" — use the labels prop / i18n`
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return findings;
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/audit-ai/required-props.ts
|
|
359
|
+
/**
|
|
360
|
+
* Flag cascade elements that omit a prop the component marks required.
|
|
361
|
+
* Elements using a spread are skipped (the prop may arrive via the spread).
|
|
362
|
+
*/
|
|
363
|
+
function findRequiredPropViolations(source, filename, contract) {
|
|
364
|
+
const findings = [];
|
|
365
|
+
const tracked = importedCascadeComponents(source);
|
|
366
|
+
for (const comp of tracked) {
|
|
367
|
+
const info = contract.components.get(comp);
|
|
368
|
+
if (!info?.hasRequiredProps) continue;
|
|
369
|
+
for (const tag of findOpeningTags(source, comp)) {
|
|
370
|
+
if (tag.hasSpread) continue;
|
|
371
|
+
const present = new Set(extractAttrNames(tag.attrs));
|
|
372
|
+
const line = lineOf(source, tag.index);
|
|
373
|
+
for (const req of info.requiredProps) {
|
|
374
|
+
if (present.has(req)) continue;
|
|
375
|
+
findings.push({
|
|
376
|
+
file: filename,
|
|
377
|
+
line,
|
|
378
|
+
component: comp,
|
|
379
|
+
prop: req,
|
|
380
|
+
level: "error",
|
|
381
|
+
rule: "missing-prop",
|
|
382
|
+
message: `<${comp}> requires "${req}"`
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return findings;
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/utils/contract.ts
|
|
391
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
392
|
+
/** Walk up from a start directory looking for the apps/docs/public dir. */
|
|
393
|
+
function findDocsPublic(startDir) {
|
|
394
|
+
let dir = startDir;
|
|
395
|
+
for (let i = 0; i < 10; i++) {
|
|
396
|
+
const candidate = join(dir, "apps", "docs", "public");
|
|
397
|
+
if (existsSync(candidate)) return candidate;
|
|
398
|
+
dir = join(dir, "..");
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/** Walk up from a start directory looking for registry.json at the repo root. */
|
|
403
|
+
function findRegistry(startDir) {
|
|
404
|
+
let dir = startDir;
|
|
405
|
+
for (let i = 0; i < 10; i++) {
|
|
406
|
+
const candidate = join(dir, "registry.json");
|
|
407
|
+
if (existsSync(candidate)) return candidate;
|
|
408
|
+
dir = join(dir, "..");
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Load the published cascade contract from local generated artifacts:
|
|
414
|
+
* - token catalog (apps/docs/public/tokens.catalog.json) → value→token map
|
|
415
|
+
* - registry.json (repo root) → component prop index
|
|
416
|
+
* - context bundle (apps/docs/public/context.json) → which components have chrome text
|
|
417
|
+
*/
|
|
418
|
+
async function loadContract(options) {
|
|
419
|
+
const docsPublic = findDocsPublic(HERE) ?? findDocsPublic(process.cwd());
|
|
420
|
+
const catalogPath = options?.catalogPath ?? (docsPublic ? join(docsPublic, "tokens.catalog.json") : null);
|
|
421
|
+
const contextPath = options?.contextPath ?? (docsPublic ? join(docsPublic, "context.json") : null);
|
|
422
|
+
const registryPath = options?.registryPath ?? findRegistry(HERE) ?? findRegistry(process.cwd());
|
|
423
|
+
if (!catalogPath || !existsSync(catalogPath)) throw new Error("token catalog not found (apps/docs/public/tokens.catalog.json)");
|
|
424
|
+
if (!registryPath || !existsSync(registryPath)) throw new Error("registry.json not found");
|
|
425
|
+
if (!contextPath || !existsSync(contextPath)) throw new Error("context bundle not found (apps/docs/public/context.json)");
|
|
426
|
+
return buildContract({
|
|
427
|
+
catalog: JSON.parse(readFileSync(catalogPath, "utf8")),
|
|
428
|
+
registry: JSON.parse(readFileSync(registryPath, "utf8")),
|
|
429
|
+
context: JSON.parse(readFileSync(contextPath, "utf8"))
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/commands/audit.ts
|
|
434
|
+
const SKIP_DIRS = new Set([
|
|
435
|
+
"node_modules",
|
|
436
|
+
"dist",
|
|
437
|
+
".git",
|
|
438
|
+
"build",
|
|
439
|
+
".next",
|
|
440
|
+
"coverage"
|
|
441
|
+
]);
|
|
442
|
+
function collectFiles(paths) {
|
|
443
|
+
const out = [];
|
|
444
|
+
const walk = (p) => {
|
|
445
|
+
if (!existsSync(p)) return;
|
|
446
|
+
if (statSync(p).isDirectory()) {
|
|
447
|
+
const base = p.split("/").pop() ?? "";
|
|
448
|
+
if (SKIP_DIRS.has(base)) return;
|
|
449
|
+
for (const entry of readdirSync(p)) walk(join(p, entry));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const ext = extname(p);
|
|
453
|
+
if (ext === ".css" || ext === ".tsx" || ext === ".ts") out.push(p);
|
|
454
|
+
};
|
|
455
|
+
for (const p of paths) walk(p);
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
function findingsFor(file, source, contract) {
|
|
459
|
+
const ext = extname(file);
|
|
460
|
+
const findings = [];
|
|
461
|
+
if (ext === ".css") findings.push(...findCssLiteralViolations(source, file, contract));
|
|
462
|
+
else if (ext === ".tsx" || ext === ".ts") {
|
|
463
|
+
findings.push(...findCssLiteralViolations(source, file, contract));
|
|
464
|
+
findings.push(...findJsxPropViolations(source, file, contract));
|
|
465
|
+
findings.push(...findRequiredPropViolations(source, file, contract));
|
|
466
|
+
findings.push(...findRawStringViolations(source, file, contract));
|
|
467
|
+
}
|
|
468
|
+
return findings;
|
|
469
|
+
}
|
|
470
|
+
function detail(f) {
|
|
471
|
+
switch (f.rule) {
|
|
472
|
+
case "hardcoded-value":
|
|
473
|
+
if (f.suggestedToken) return `${f.value} → var(${f.suggestedToken})`;
|
|
474
|
+
return `${f.value} → ${f.allMatches?.join(" | ") ?? "(ambiguous)"}`;
|
|
475
|
+
case "unknown-prop": return `<${f.component} ${f.prop}>`;
|
|
476
|
+
case "spread-suppressed": return `<${f.component} {...}> (props not checked)`;
|
|
477
|
+
case "missing-prop": return `<${f.component}> requires "${f.prop}"`;
|
|
478
|
+
case "raw-string": return `"${f.text}" → use labels prop / i18n`;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function levelLabel(level) {
|
|
482
|
+
return level === "warn" ? "warn" : level;
|
|
483
|
+
}
|
|
484
|
+
function renderFindings(findings) {
|
|
485
|
+
if (findings.length === 0) {
|
|
486
|
+
console.log("cascade audit --ai: no findings.");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const rows = findings.map((f) => ({
|
|
490
|
+
loc: `${f.file}:${f.line}`,
|
|
491
|
+
level: levelLabel(f.level),
|
|
492
|
+
rule: f.rule,
|
|
493
|
+
detail: detail(f)
|
|
494
|
+
}));
|
|
495
|
+
const locW = Math.max(...rows.map((r) => r.loc.length), 8);
|
|
496
|
+
const lvlW = Math.max(...rows.map((r) => r.level.length), 5);
|
|
497
|
+
const ruleW = Math.max(...rows.map((r) => r.rule.length), 4);
|
|
498
|
+
for (const r of rows) console.log(`${r.loc.padEnd(locW)} ${r.level.padEnd(lvlW)} ${r.rule.padEnd(ruleW)} ${r.detail}`);
|
|
499
|
+
console.log("---");
|
|
500
|
+
const errors = findings.filter((f) => f.level === "error").length;
|
|
501
|
+
const warnings = findings.filter((f) => f.level === "warn").length;
|
|
502
|
+
const infos = findings.filter((f) => f.level === "info").length;
|
|
503
|
+
const parts = [`${errors} error${errors === 1 ? "" : "s"}`, `${warnings} warning${warnings === 1 ? "" : "s"}`];
|
|
504
|
+
if (infos) parts.push(`${infos} info`);
|
|
505
|
+
console.log(parts.join(", "));
|
|
506
|
+
}
|
|
507
|
+
function escapeRe(s) {
|
|
508
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Pure literal→token rewrite. Only rewrites simple `property: #hex;` style
|
|
512
|
+
* declarations where exactly one token matches. Returns the new source and how
|
|
513
|
+
* many findings were resolved (verified by re-running the checker).
|
|
514
|
+
*/
|
|
515
|
+
function fixCssLiterals(source, file, contract) {
|
|
516
|
+
const fixable = findCssLiteralViolations(source, file, contract).filter((f) => f.level === "error" && f.suggestedToken && /^#[0-9a-fA-F]{3,8}$/.test(f.value));
|
|
517
|
+
let next = source;
|
|
518
|
+
for (const f of fixable) {
|
|
519
|
+
const re = new RegExp(`(${escapeRe(f.property)}\\s*:\\s*)${escapeRe(f.value)}(\\s*[;}])`, "gi");
|
|
520
|
+
next = next.replace(re, `$1var(${f.suggestedToken})$2`);
|
|
521
|
+
}
|
|
522
|
+
if (next === source) return {
|
|
523
|
+
source,
|
|
524
|
+
fixed: 0
|
|
525
|
+
};
|
|
526
|
+
const remaining = findCssLiteralViolations(next, file, contract).filter((f) => f.level === "error" && f.suggestedToken).length;
|
|
527
|
+
return {
|
|
528
|
+
source: next,
|
|
529
|
+
fixed: fixable.length - remaining
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
/** Apply {@link fixCssLiterals} to each .css file on disk. */
|
|
533
|
+
function applyFixes(files, contract) {
|
|
534
|
+
let fixed = 0;
|
|
535
|
+
for (const file of files) {
|
|
536
|
+
if (extname(file) !== ".css") continue;
|
|
537
|
+
const { source, fixed: n } = fixCssLiterals(readFileSync(file, "utf8"), file, contract);
|
|
538
|
+
if (n > 0) {
|
|
539
|
+
writeFileSync(file, source);
|
|
540
|
+
fixed += n;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return fixed;
|
|
544
|
+
}
|
|
545
|
+
async function audit(args, _config) {
|
|
546
|
+
if (!args.includes("--ai")) {
|
|
547
|
+
console.log("cascade audit: use --ai to audit AI-generated code against the cascade contract");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const jsonOutput = args.includes("--json");
|
|
551
|
+
const fixMode = args.includes("--fix");
|
|
552
|
+
const levelIdx = args.indexOf("--level");
|
|
553
|
+
const minLevel = levelIdx >= 0 ? args[levelIdx + 1] ?? "error" : "error";
|
|
554
|
+
const paths = args.filter((a, i) => {
|
|
555
|
+
if (a.startsWith("--")) return false;
|
|
556
|
+
if (levelIdx >= 0 && i === levelIdx + 1) return false;
|
|
557
|
+
return true;
|
|
558
|
+
});
|
|
559
|
+
let contract;
|
|
560
|
+
try {
|
|
561
|
+
contract = await loadContract();
|
|
562
|
+
} catch (e) {
|
|
563
|
+
console.error(`Contract unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
564
|
+
process.exitCode = 2;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const files = collectFiles(paths.length ? paths : [process.cwd()]);
|
|
568
|
+
if (fixMode) {
|
|
569
|
+
const n = applyFixes(files, contract);
|
|
570
|
+
console.log(`cascade audit --ai --fix: rewrote ${n} literal${n === 1 ? "" : "s"} to tokens.`);
|
|
571
|
+
}
|
|
572
|
+
const allFindings = [];
|
|
573
|
+
for (const file of files) allFindings.push(...findingsFor(file, readFileSync(file, "utf8"), contract));
|
|
574
|
+
if (jsonOutput) console.log(JSON.stringify(allFindings, null, 2));
|
|
575
|
+
else renderFindings(allFindings);
|
|
576
|
+
const hasErrors = allFindings.some((f) => f.level === "error");
|
|
577
|
+
if (minLevel === "error" && hasErrors) process.exitCode = 1;
|
|
578
|
+
if (minLevel === "warn" && allFindings.some((f) => f.level !== "info")) process.exitCode = 1;
|
|
579
|
+
}
|
|
580
|
+
//#endregion
|
|
581
|
+
export { audit };
|
|
582
|
+
|
|
583
|
+
//# sourceMappingURL=audit-AHr4MDMK.mjs.map
|