@open-dy/vibecoding-cli 0.0.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +47 -0
  2. package/assets/schemas/custom-card-schema.json +102 -0
  3. package/bin/vibecoding +89 -0
  4. package/bin/vibecoding-dev +308 -0
  5. package/bin/vibecoding-interest-card.mjs +35 -0
  6. package/bin/vibecoding.mjs +9 -0
  7. package/package.json +58 -0
  8. package/src/adapters/interest-card/opencard/commands.mjs +62 -0
  9. package/src/adapters/interest-card/opencard/dev-watch.mjs +203 -0
  10. package/src/adapters/interest-card/opencard/dev_run.sh +126 -0
  11. package/src/adapters/interest-card/opencard/eslint-plugin-opencard-taro.mjs +278 -0
  12. package/src/adapters/interest-card/opencard/lint-lynx-css.mjs +676 -0
  13. package/src/adapters/interest-card/opencard/lint.sh +131 -0
  14. package/src/adapters/interest-card/opencard/lynx-css-rules.mjs +508 -0
  15. package/src/adapters/interest-card/opencard/pack.sh +27 -0
  16. package/src/adapters/interest-card/opencard/validate-opencard-structure.mjs +65 -0
  17. package/src/adapters/interest-card/opencard/validate-widget-shell.mjs +902 -0
  18. package/src/adapters/interest-card/opencard/validate.mjs +11 -0
  19. package/src/adapters/interest-card/opencard/write-web-config.mjs +17 -0
  20. package/src/build/opencard-environment.mjs +49 -0
  21. package/src/build/turbo-config.mjs +140 -0
  22. package/src/cli.mjs +104 -0
  23. package/src/commands/build.mjs +138 -0
  24. package/src/commands/db.mjs +14 -0
  25. package/src/commands/dev.mjs +319 -0
  26. package/src/commands/lint.mjs +176 -0
  27. package/src/commands/pack.mjs +169 -0
  28. package/src/commands/turbo.mjs +36 -0
  29. package/src/commands/validate.mjs +26 -0
  30. package/src/commands/verify.mjs +602 -0
  31. package/src/core/args.mjs +101 -0
  32. package/src/core/context.mjs +101 -0
  33. package/src/core/environment.mjs +55 -0
  34. package/src/core/errors.mjs +14 -0
  35. package/src/core/ports.mjs +76 -0
  36. package/src/core/process-output.mjs +25 -0
  37. package/src/core/runner.mjs +90 -0
  38. package/src/core/service-log-context.mjs +27 -0
  39. package/src/core/web-config.mjs +13 -0
  40. package/src/lint/lint-lynx-css.mjs +244 -0
  41. package/src/lint/lynx-css-rules.mjs +138 -0
  42. package/src/preview/dev-watch.mjs +38 -0
  43. package/src/preview/start-preview.mjs +213 -0
  44. package/src/preview/watch-card-routing.mjs +89 -0
  45. package/src/scripts/pack.mjs +15 -0
  46. package/src/validators/dependencies.mjs +34 -0
  47. package/src/validators/empty-project.mjs +29 -0
  48. package/src/validators/feed-card.cjs +156 -0
  49. package/src/validators/feed-card.mjs +32 -0
  50. package/src/validators/opencard-structure.mjs +58 -0
  51. package/src/validators/routes.cjs +470 -0
  52. package/src/validators/routes.mjs +17 -0
  53. package/src/validators/sql.mjs +57 -0
  54. package/src/validators/widget-shell.mjs +107 -0
@@ -0,0 +1,676 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * opencard Lynx CSS 机械禁写检查:扫 src 下 css,并对照 TSX 中 Text 的 class。
4
+ * 规则见 lynx-css-rules.mjs;inline style 由 eslint-plugin-opencard-taro 复用同一套规则。
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import {
11
+ formatFinding,
12
+ isTextContainerDeclaration,
13
+ lintAtRule,
14
+ lintDeclaration,
15
+ lintSelector,
16
+ } from './lynx-css-rules.mjs';
17
+
18
+ const __dirname = path.join(process.env.VIBECODING_OPENCARD_ROOT || process.cwd(), 'scripts');
19
+ const ROOT = path.resolve(__dirname, '..');
20
+ const SRC = path.join(ROOT, 'src');
21
+ const require = createRequire(path.join(ROOT, 'scripts', 'package.json'));
22
+
23
+ // Keep string literals intact: comment delimiters inside quoted URLs are data.
24
+ function scanComments(source) {
25
+ const chars = source.split('');
26
+ const errors = [];
27
+ let quote = '';
28
+ for (let i = 0; i < source.length; i += 1) {
29
+ if (quote) {
30
+ if (source[i] === '\\') i += 1;
31
+ else if (source[i] === quote) quote = '';
32
+ continue;
33
+ }
34
+ if (source[i] === '"' || source[i] === "'") {
35
+ quote = source[i];
36
+ continue;
37
+ }
38
+ if (source.slice(i, i + 2) === '*/') {
39
+ errors.push({ index: i, message: '孤立的 CSS 注释结束符 */;检查文件是否缺失开头。' });
40
+ i += 1;
41
+ } else if (source.slice(i, i + 2) === '/*') {
42
+ const end = source.indexOf('*/', i + 2);
43
+ if (end < 0) errors.push({ index: i, message: 'CSS 注释未闭合,缺少 */。' });
44
+ const stop = end < 0 ? source.length : end + 2;
45
+ for (let j = i; j < stop; j += 1) {
46
+ if (chars[j] !== '\n') chars[j] = ' ';
47
+ }
48
+ i = stop - 1;
49
+ }
50
+ }
51
+ return { content: chars.join(''), errors };
52
+ }
53
+
54
+ export function stripCommentsPreservingLines(source) {
55
+ return scanComments(source).content;
56
+ }
57
+
58
+ function lineAt(source, index) {
59
+ const before = source.slice(0, index);
60
+ return {
61
+ line: before.split('\n').length,
62
+ column: index - before.lastIndexOf('\n'),
63
+ };
64
+ }
65
+
66
+
67
+
68
+ function isQuote(ch) {
69
+ return ch === '"' || ch === "'";
70
+ }
71
+
72
+ function skipString(text, i, end) {
73
+ const q = text[i];
74
+ i += 1;
75
+ while (i < end) {
76
+ if (text[i] === '\\') {
77
+ i += 2;
78
+ continue;
79
+ }
80
+ if (text[i] === q) return i + 1;
81
+ i += 1;
82
+ }
83
+ return end;
84
+ }
85
+
86
+ function skipWs(text, i, end) {
87
+ while (i < end && /\s/.test(text[i])) i += 1;
88
+ return i;
89
+ }
90
+
91
+ function indexOfUnquoted(text, needle, from, end) {
92
+ let depth = 0;
93
+ for (let i = from; i < end; i += 1) {
94
+ const ch = text[i];
95
+ if (isQuote(ch)) {
96
+ i = skipString(text, i, end) - 1;
97
+ continue;
98
+ }
99
+ if (ch === '(') depth += 1;
100
+ else if (ch === ')') depth = Math.max(0, depth - 1);
101
+ else if (depth === 0 && ch === needle) return i;
102
+ }
103
+ return -1;
104
+ }
105
+
106
+ function matchingBrace(text, open, end) {
107
+ let depth = 0;
108
+ for (let i = open; i < end; i += 1) {
109
+ const ch = text[i];
110
+ if (isQuote(ch)) {
111
+ i = skipString(text, i, end) - 1;
112
+ continue;
113
+ }
114
+ if (ch === '{') depth += 1;
115
+ else if (ch === '}') {
116
+ depth -= 1;
117
+ if (depth === 0) return i;
118
+ }
119
+ }
120
+ return end - 1;
121
+ }
122
+
123
+ function makeLineAt(text) {
124
+ const starts = [0];
125
+ for (let i = 0; i < text.length; i += 1) {
126
+ if (text[i] === '\n') starts.push(i + 1);
127
+ }
128
+ return (index) => {
129
+ let lo = 0;
130
+ let hi = starts.length - 1;
131
+ while (lo < hi) {
132
+ const mid = (lo + hi + 1) >> 1;
133
+ if (starts[mid] <= index) lo = mid;
134
+ else hi = mid - 1;
135
+ }
136
+ return { line: lo + 1, column: index - starts[lo] + 1 };
137
+ };
138
+ }
139
+
140
+ function parseDecl(text) {
141
+ const colon = indexOfUnquoted(text, ':', 0, text.length);
142
+ if (colon < 0) return null;
143
+ const prop = text.slice(0, colon).trim();
144
+ const value = text.slice(colon + 1).trim();
145
+ if (!prop || !value) return null;
146
+ return { prop, value };
147
+ }
148
+
149
+ export function walkCss(source, onNode) {
150
+ const text = stripCommentsPreservingLines(source);
151
+ const locAt = makeLineAt(text);
152
+
153
+ function walk(start, end, inRule) {
154
+ let i = start;
155
+ while (i < end) {
156
+ i = skipWs(text, i, end);
157
+ if (i >= end) break;
158
+ if (text[i] === '}') return i + 1;
159
+
160
+ if (text[i] === '@') {
161
+ const atLoc = locAt(i);
162
+ let j = i + 1;
163
+ while (j < end && /[A-Za-z-]/.test(text[j])) j += 1;
164
+ const name = text.slice(i + 1, j);
165
+ j = skipWs(text, j, end);
166
+ const brace = indexOfUnquoted(text, '{', j, end);
167
+ const semi = indexOfUnquoted(text, ';', j, end);
168
+ const closer = indexOfUnquoted(text, '}', j, end);
169
+ const hasBrace =
170
+ brace >= 0 && (semi < 0 || brace < semi) && (closer < 0 || brace < closer);
171
+ const preludeEnd = hasBrace ? brace : semi >= 0 && (closer < 0 || semi < closer) ? semi : end;
172
+ onNode({
173
+ type: 'atrule',
174
+ name,
175
+ prelude: text.slice(j, preludeEnd).trim(),
176
+ line: atLoc.line,
177
+ column: atLoc.column,
178
+ });
179
+ if (hasBrace) {
180
+ const close = matchingBrace(text, brace, end);
181
+ walk(brace + 1, close, false);
182
+ i = close + 1;
183
+ } else {
184
+ i = preludeEnd < end ? preludeEnd + 1 : end;
185
+ }
186
+ continue;
187
+ }
188
+
189
+ const brace = indexOfUnquoted(text, '{', i, end);
190
+ const semi = indexOfUnquoted(text, ';', i, end);
191
+ const closer = indexOfUnquoted(text, '}', i, end);
192
+
193
+ const semiFirst =
194
+ inRule &&
195
+ semi >= 0 &&
196
+ (brace < 0 || semi < brace) &&
197
+ (closer < 0 || semi < closer);
198
+ if (semiFirst) {
199
+ const declLoc = locAt(i);
200
+ const decl = parseDecl(text.slice(i, semi));
201
+ if (decl) {
202
+ onNode({
203
+ type: 'decl',
204
+ prop: decl.prop,
205
+ value: decl.value,
206
+ line: declLoc.line,
207
+ column: declLoc.column,
208
+ });
209
+ }
210
+ i = semi + 1;
211
+ continue;
212
+ }
213
+
214
+ const closerFirst =
215
+ inRule && closer >= 0 && (brace < 0 || closer < brace) && (semi < 0 || closer < semi);
216
+ if (closerFirst) {
217
+ const leftover = text.slice(i, closer).trim();
218
+ if (leftover) {
219
+ const declLoc = locAt(i);
220
+ const decl = parseDecl(leftover);
221
+ if (decl) {
222
+ onNode({
223
+ type: 'decl',
224
+ prop: decl.prop,
225
+ value: decl.value,
226
+ line: declLoc.line,
227
+ column: declLoc.column,
228
+ });
229
+ }
230
+ }
231
+ return closer + 1;
232
+ }
233
+
234
+ if (brace >= 0 && (closer < 0 || brace < closer)) {
235
+ const selector = text.slice(i, brace).trim();
236
+ const selLoc = locAt(i);
237
+ onNode({
238
+ type: 'rule',
239
+ selector,
240
+ line: selLoc.line,
241
+ column: selLoc.column,
242
+ });
243
+ const close = matchingBrace(text, brace, end);
244
+ walk(brace + 1, close, true);
245
+ i = close + 1;
246
+ continue;
247
+ }
248
+
249
+ break;
250
+ }
251
+ return i;
252
+ }
253
+
254
+ walk(0, text.length, false);
255
+ }
256
+
257
+ function isAppCss(filename) {
258
+ const normalized = filename.replace(/\\/g, '/');
259
+ return normalized === 'src/app.css' || normalized.endsWith('/src/app.css');
260
+ }
261
+
262
+ function lastCompoundSelector(selector) {
263
+ const parts = [];
264
+ let current = '';
265
+ let depth = 0;
266
+ for (let i = 0; i < selector.length; i += 1) {
267
+ const ch = selector[i];
268
+ if (ch === '(') depth += 1;
269
+ if (ch === ')') depth = Math.max(0, depth - 1);
270
+ if (depth === 0 && ch === ',') {
271
+ parts.push(current.trim());
272
+ current = '';
273
+ } else {
274
+ current += ch;
275
+ }
276
+ }
277
+ if (current.trim()) parts.push(current.trim());
278
+
279
+ return parts.map((part) => {
280
+ let idx = 0;
281
+ for (let i = 0; i < part.length; i += 1) {
282
+ const ch = part[i];
283
+ if (ch === '(') {
284
+ let d = 1;
285
+ i += 1;
286
+ while (i < part.length && d > 0) {
287
+ if (part[i] === '(') d += 1;
288
+ else if (part[i] === ')') d -= 1;
289
+ i += 1;
290
+ }
291
+ i -= 1;
292
+ continue;
293
+ }
294
+ if ((ch === ' ' || ch === '>' || ch === '+' || ch === '~') && i + 1 < part.length) {
295
+ idx = i + 1;
296
+ }
297
+ }
298
+ return part.slice(idx).trim();
299
+ });
300
+ }
301
+
302
+ function classesInCompound(compound) {
303
+ const names = [];
304
+ const re = /\.(-?[_A-Za-z]+[_A-Za-z0-9-]*)/g;
305
+ let match;
306
+ while ((match = re.exec(compound))) names.push(match[1]);
307
+ return names;
308
+ }
309
+
310
+ export function lintCssText(source, { filename = 'input.css' } = {}) {
311
+ const allowRoot = isAppCss(filename);
312
+ const findings = scanComments(source).errors.map(({ index, message }) => ({
313
+ id: "css-comment", severity: "error", ...lineAt(source, index), message,
314
+ }));
315
+ const rules = [];
316
+ let currentRule = null;
317
+
318
+ walkCss(source, (node) => {
319
+ if (node.type === 'atrule') {
320
+ for (const item of lintAtRule(node.name)) {
321
+ findings.push({ ...item, line: node.line, column: node.column, selector: `@${node.name}` });
322
+ }
323
+ currentRule = null;
324
+ return;
325
+ }
326
+ if (node.type === 'rule') {
327
+ currentRule = {
328
+ selector: node.selector,
329
+ line: node.line,
330
+ column: node.column,
331
+ decls: [],
332
+ };
333
+ rules.push(currentRule);
334
+ for (const item of lintSelector(node.selector, { allowRoot })) {
335
+ findings.push({ ...item, line: node.line, column: node.column, selector: node.selector });
336
+ }
337
+ return;
338
+ }
339
+ if (node.type === 'decl') {
340
+ if (currentRule) currentRule.decls.push(node);
341
+ for (const item of lintDeclaration(node.prop, node.value)) {
342
+ findings.push({
343
+ ...item,
344
+ line: node.line,
345
+ column: node.column,
346
+ selector: currentRule?.selector,
347
+ });
348
+ }
349
+ }
350
+ });
351
+
352
+ return { findings, rules };
353
+ }
354
+
355
+ function collectStaticStrings(node, result = []) {
356
+ if (!node) return result;
357
+ if (node.type === 'StringLiteral' || (node.type === 'Literal' && typeof node.value === 'string')) {
358
+ result.push(node.value);
359
+ return result;
360
+ }
361
+ if (node.type === 'TemplateLiteral') {
362
+ for (const quasi of node.quasis) {
363
+ if (quasi.value.cooked) result.push(quasi.value.cooked);
364
+ }
365
+ for (const expression of node.expressions) collectStaticStrings(expression, result);
366
+ return result;
367
+ }
368
+ if (node.type === 'JSXExpressionContainer') {
369
+ return collectStaticStrings(node.expression, result);
370
+ }
371
+ for (const key of [
372
+ 'left',
373
+ 'right',
374
+ 'argument',
375
+ 'callee',
376
+ 'test',
377
+ 'consequent',
378
+ 'alternate',
379
+ 'object',
380
+ 'property',
381
+ ]) {
382
+ if (node[key]) collectStaticStrings(node[key], result);
383
+ }
384
+ for (const key of ['arguments', 'elements', 'expressions']) {
385
+ if (Array.isArray(node[key])) {
386
+ for (const child of node[key]) collectStaticStrings(child, result);
387
+ }
388
+ }
389
+ return result;
390
+ }
391
+
392
+ function loadBabel() {
393
+ try {
394
+ const parser = require('@babel/parser');
395
+ const traverseMod = require('@babel/traverse');
396
+ const traverse = traverseMod.default ?? traverseMod;
397
+ return { parser, traverse };
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+
403
+ function addClassTokens(classes, chunk) {
404
+ for (const token of String(chunk).split(/\s+/)) {
405
+ if (token) classes.add(token);
406
+ }
407
+ }
408
+
409
+ function collectTextClassesRegex(source) {
410
+ const classes = new Set();
411
+ const tagRe = /<Text\b[\s\S]*?>/g;
412
+ let match;
413
+ while ((match = tagRe.exec(source))) {
414
+ const cn = /className\s*=\s*(?:\{\s*)?(["'`])([^"'`]+)\1/.exec(match[0]);
415
+ if (cn) addClassTokens(classes, cn[2]);
416
+ }
417
+ return classes;
418
+ }
419
+
420
+ export function collectTextClassesFromTsx(source) {
421
+ const babel = loadBabel();
422
+ if (!babel) return collectTextClassesRegex(source);
423
+ let ast;
424
+ try {
425
+ ast = babel.parser.parse(source, {
426
+ sourceType: 'module',
427
+ plugins: ['jsx', 'typescript'],
428
+ });
429
+ } catch {
430
+ return collectTextClassesRegex(source);
431
+ }
432
+ const classes = new Set();
433
+ babel.traverse(ast, {
434
+ JSXOpeningElement(pathNode) {
435
+ const name = pathNode.node.name;
436
+ if (name.type !== 'JSXIdentifier' || name.name !== 'Text') return;
437
+ for (const attr of pathNode.node.attributes) {
438
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'className') continue;
439
+ for (const chunk of collectStaticStrings(attr.value)) {
440
+ addClassTokens(classes, chunk);
441
+ }
442
+ }
443
+ },
444
+ });
445
+ return classes;
446
+ }
447
+
448
+ function listFiles(dir, test, acc = []) {
449
+ if (!fs.existsSync(dir)) return acc;
450
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
451
+ const target = path.join(dir, entry.name);
452
+ if (entry.isDirectory()) listFiles(target, test, acc);
453
+ else if (test(entry.name)) acc.push(target);
454
+ }
455
+ return acc;
456
+ }
457
+
458
+ function rel(file) {
459
+ return path.relative(ROOT, file).replace(/\\/g, '/');
460
+ }
461
+
462
+ function lintTextContainerUsage(rules, textClasses, filename, extraFindings) {
463
+ if (textClasses.size === 0) return;
464
+ for (const rule of rules) {
465
+ const subjects = lastCompoundSelector(rule.selector);
466
+ const hitsText = subjects.some((compound) =>
467
+ classesInCompound(compound).some((name) => textClasses.has(name)),
468
+ );
469
+ if (!hitsText) continue;
470
+ for (const decl of rule.decls) {
471
+ if (!isTextContainerDeclaration(decl.prop, decl.value)) continue;
472
+ extraFindings.push({
473
+ id: 'text-container',
474
+ message: `Text 不是 Flex/Grid 容器,不要在命中 Text 的选择器上写 ${decl.prop}: ${decl.value}。多个节点的排列由外层 View 承担。`,
475
+ line: decl.line,
476
+ column: decl.column,
477
+ selector: rule.selector,
478
+ file: filename,
479
+ });
480
+ }
481
+ }
482
+ }
483
+
484
+ export function lintProject(srcDir = SRC) {
485
+ const cssFiles = listFiles(srcDir, (name) => name.endsWith('.css'));
486
+ const tsxFiles = listFiles(srcDir, (name) => /\.[jt]sx$/.test(name));
487
+ const textClasses = new Set();
488
+ for (const file of tsxFiles) {
489
+ for (const name of collectTextClassesFromTsx(fs.readFileSync(file, 'utf8'))) {
490
+ textClasses.add(name);
491
+ }
492
+ }
493
+
494
+ const reports = [];
495
+ const allRules = [];
496
+ for (const file of cssFiles) {
497
+ const filename = rel(file);
498
+ const { findings, rules } = lintCssText(fs.readFileSync(file, 'utf8'), { filename });
499
+ for (const item of findings) reports.push({ ...item, file: filename });
500
+ for (const rule of rules) allRules.push({ ...rule, file: filename });
501
+ }
502
+ for (const file of cssFiles) {
503
+ const filename = rel(file);
504
+ const subset = allRules.filter((rule) => rule.file === filename);
505
+ lintTextContainerUsage(subset, textClasses, filename, reports);
506
+ }
507
+ return reports;
508
+ }
509
+
510
+ function printReports(reports) {
511
+ for (const item of reports) {
512
+ console.error(formatFinding(item.file, item.line, item.column, item.message));
513
+ }
514
+ }
515
+
516
+ function assertSelfTest() {
517
+ const cases = [
518
+ {
519
+ css: '@media (min-width: 768px) { .a { color: red; } }',
520
+ id: 'forbidden-at-rule',
521
+ },
522
+ {
523
+ css: '.a:first-child { color: red; }',
524
+ id: 'structural-pseudo',
525
+ },
526
+ {
527
+ css: '.a::before { color: red; }',
528
+ id: 'pseudo-element',
529
+ },
530
+ {
531
+ css: ':root { --x: 1px; }',
532
+ file: 'src/pages/index/index.css',
533
+ id: 'root-selector',
534
+ },
535
+ {
536
+ css: ':root { --x: 1px; }',
537
+ file: 'src/app.css',
538
+ id: null,
539
+ },
540
+ {
541
+ css: '.a { display: inline-block; }',
542
+ id: 'forbidden-value',
543
+ },
544
+ {
545
+ css: '.a { display: flex; }',
546
+ id: null,
547
+ },
548
+ {
549
+ css: '.a { flex: 1; }',
550
+ id: 'flex-shorthand',
551
+ },
552
+ {
553
+ css: '.a { flex: 1 1 0; }',
554
+ id: null,
555
+ },
556
+ {
557
+ css: '.a { overflow-y: auto; }',
558
+ id: 'forbidden-value',
559
+ },
560
+ {
561
+ css: '.a { overflow-x: auto; }',
562
+ id: null,
563
+ },
564
+ {
565
+ css: '.a { position: sticky; }',
566
+ id: 'forbidden-value',
567
+ },
568
+ {
569
+ css: '.a { grid-column: 1 / 3; }',
570
+ id: 'forbidden-property',
571
+ },
572
+ {
573
+ css: '.a { width: calc(100% - 8px); }',
574
+ id: null,
575
+ },
576
+ {
577
+ css: '.a { border-radius: calc(8px + 2px); }',
578
+ id: 'calc-property',
579
+ },
580
+ {
581
+ css: '.a { width: min(100%, 40px); }',
582
+ id: 'forbidden-function',
583
+ },
584
+ {
585
+ css: '.a { padding-bottom: -1px; }',
586
+ id: 'negative-padding',
587
+ },
588
+ {
589
+ css: '.a { background-image: url("./x.svg"); }',
590
+ id: 'svg-url',
591
+ },
592
+ {
593
+ css: '.a { margin-left: 1in; }',
594
+ id: 'forbidden-unit',
595
+ },
596
+ {
597
+ css: '.a { animation-timing-function: steps(5); }',
598
+ id: 'steps-direction',
599
+ },
600
+ {
601
+ css: '.a { animation-timing-function: steps(5, end); }',
602
+ id: null,
603
+ },
604
+ {
605
+ css: '.a { pointer-events: none; }',
606
+ id: 'forbidden-property',
607
+ },
608
+ {
609
+ css: '/* display: inline-block; @media (min-width: 1px) {} */ .a { color: red; }',
610
+ id: null,
611
+ },
612
+ {
613
+ css: '.a { display: flex }',
614
+ id: null,
615
+ },
616
+ {
617
+ css: '.a { flex-basis: min-content; }',
618
+ id: 'min-content-flex-basis',
619
+ },
620
+ {
621
+ css: '.label:disabled { opacity: 0.5; }',
622
+ id: 'state-pseudo',
623
+ },
624
+ {
625
+ css: '.a { content: "*"; }',
626
+ id: 'forbidden-property',
627
+ },
628
+ ];
629
+
630
+ const failures = [];
631
+ for (const item of cases) {
632
+ const { findings } = lintCssText(item.css, { filename: item.file || 'src/pages/x.css' });
633
+ const hit = findings.some((f) => f.id === item.id);
634
+ const ok = item.id === null ? findings.length === 0 : hit;
635
+ if (!ok) {
636
+ failures.push({
637
+ css: item.css,
638
+ expected: item.id,
639
+ actual: findings.map((f) => f.id),
640
+ });
641
+ }
642
+ }
643
+
644
+ const textCss = '.option-key { display: flex; align-items: center; }';
645
+ const { rules } = lintCssText(textCss, { filename: 'src/pages/x.css' });
646
+ const extra = [];
647
+ lintTextContainerUsage(rules, new Set(['option-key']), 'src/pages/x.css', extra);
648
+ if (!extra.some((item) => item.id === 'text-container')) {
649
+ failures.push({ css: textCss, expected: 'text-container', actual: extra.map((f) => f.id) });
650
+ }
651
+
652
+ if (failures.length) {
653
+ console.error(`[lynx-css self-test] ${failures.length} failed`);
654
+ for (const fail of failures) {
655
+ console.error(` expected ${fail.expected}, got ${JSON.stringify(fail.actual)}`);
656
+ console.error(` css: ${fail.css}`);
657
+ }
658
+ process.exit(1);
659
+ }
660
+ console.log(`[lynx-css self-test] ${cases.length + 1} passed`);
661
+ }
662
+
663
+ export function main() {
664
+ if (process.argv.includes('--self-test')) {
665
+ assertSelfTest();
666
+ return;
667
+ }
668
+ const reports = lintProject();
669
+ if (reports.length === 0) return;
670
+ console.error(`ERROR: Lynx CSS lint 失败(${reports.length}):机械禁写见 rendering.md,不要手工 grep。`);
671
+ printReports(reports);
672
+ process.exit(1);
673
+ }
674
+
675
+ const invoked = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
676
+ if (invoked) main();