canicode 0.4.1 → 0.5.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/README.md CHANGED
@@ -179,6 +179,43 @@ See [`examples/config.json`](examples/config.json) | [`docs/CUSTOMIZATION.md`](d
179
179
 
180
180
  </details>
181
181
 
182
+ <details>
183
+ <summary><strong>Custom Rules</strong></summary>
184
+
185
+ Add project-specific checks with declarative pattern matching:
186
+
187
+ ```bash
188
+ canicode analyze <url> --custom-rules ./my-rules.json
189
+ ```
190
+
191
+ ```json
192
+ [
193
+ {
194
+ "id": "icon-not-component",
195
+ "category": "component",
196
+ "severity": "blocking",
197
+ "score": -10,
198
+ "match": {
199
+ "type": ["FRAME", "GROUP"],
200
+ "maxWidth": 48,
201
+ "maxHeight": 48,
202
+ "hasChildren": true,
203
+ "nameContains": "icon"
204
+ },
205
+ "message": "\"{name}\" is an icon but not a component",
206
+ "why": "Icons that are not components cannot be reused consistently.",
207
+ "impact": "Developers will hardcode icon SVGs instead of using a shared component.",
208
+ "fix": "Convert this icon to a component and publish it to the design system library."
209
+ }
210
+ ]
211
+ ```
212
+
213
+ Conditions use AND logic — all must match for the rule to fire. Available conditions: `type`, `notType`, `nameContains`, `nameNotContains`, `namePattern`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `hasAutoLayout`, `hasChildren`, `minChildren`, `maxChildren`, `isComponent`, `isInstance`, `hasComponentId`, `isVisible`, `hasFills`, `hasStrokes`, `hasEffects`, `minDepth`, `maxDepth`.
214
+
215
+ See [`examples/custom-rules.json`](examples/custom-rules.json) | [`docs/CUSTOMIZATION.md`](docs/CUSTOMIZATION.md)
216
+
217
+ </details>
218
+
182
219
  <details>
183
220
  <summary><strong>Scoring Algorithm</strong></summary>
184
221
 
@@ -283,7 +320,7 @@ pnpm lint # type check
283
320
  - [x] **Phase 2** — 4-agent calibration pipeline, `/calibrate-loop` debate loop
284
321
  - [x] **Phase 3** — Config overrides, MCP server, Claude Skills
285
322
  - [x] **Phase 4** — Figma comment from report (per-issue "Comment" button in HTML report, posts to Figma node via API)
286
- - [ ] **Phase 5** — Custom rules with pattern matching (node name/type/attribute conditions)
323
+ - [x] **Phase 5** — Custom rules with pattern matching (node name/type/attribute conditions)
287
324
  - [ ] **Phase 6** — Screenshot comparison (Figma vs AI-generated code, visual diff)
288
325
 
289
326
  ## License
package/dist/cli/index.js CHANGED
@@ -1487,15 +1487,50 @@ function formatScoreSummary(report) {
1487
1487
  lines.push(` Total: ${report.summary.totalIssues}`);
1488
1488
  return lines.join("\n");
1489
1489
  }
1490
+ var MatchConditionSchema = z.object({
1491
+ // Node type conditions
1492
+ type: z.array(z.string()).optional(),
1493
+ notType: z.array(z.string()).optional(),
1494
+ // Name conditions (case-insensitive, substring match)
1495
+ nameContains: z.string().optional(),
1496
+ nameNotContains: z.string().optional(),
1497
+ namePattern: z.string().optional(),
1498
+ // Size conditions
1499
+ minWidth: z.number().optional(),
1500
+ maxWidth: z.number().optional(),
1501
+ minHeight: z.number().optional(),
1502
+ maxHeight: z.number().optional(),
1503
+ // Layout conditions
1504
+ hasAutoLayout: z.boolean().optional(),
1505
+ hasChildren: z.boolean().optional(),
1506
+ minChildren: z.number().optional(),
1507
+ maxChildren: z.number().optional(),
1508
+ // Component conditions
1509
+ isComponent: z.boolean().optional(),
1510
+ isInstance: z.boolean().optional(),
1511
+ hasComponentId: z.boolean().optional(),
1512
+ // Visibility
1513
+ isVisible: z.boolean().optional(),
1514
+ // Fill/style conditions
1515
+ hasFills: z.boolean().optional(),
1516
+ hasStrokes: z.boolean().optional(),
1517
+ hasEffects: z.boolean().optional(),
1518
+ // Depth condition
1519
+ minDepth: z.number().optional(),
1520
+ maxDepth: z.number().optional()
1521
+ });
1490
1522
  var CustomRuleSchema = z.object({
1491
1523
  id: z.string(),
1492
1524
  category: CategorySchema,
1493
1525
  severity: SeveritySchema,
1494
1526
  score: z.number().int().max(0),
1495
- prompt: z.string(),
1527
+ match: MatchConditionSchema,
1528
+ message: z.string().optional(),
1496
1529
  why: z.string(),
1497
1530
  impact: z.string(),
1498
- fix: z.string()
1531
+ fix: z.string(),
1532
+ // Backward compat: silently ignore the old prompt field
1533
+ prompt: z.string().optional()
1499
1534
  });
1500
1535
  var CustomRulesFileSchema = z.array(CustomRuleSchema);
1501
1536
 
@@ -1508,6 +1543,7 @@ async function loadCustomRules(filePath) {
1508
1543
  const rules = [];
1509
1544
  const configs = {};
1510
1545
  for (const cr of customRules) {
1546
+ if (!cr.match) continue;
1511
1547
  rules.push(toRule(cr));
1512
1548
  configs[cr.id] = {
1513
1549
  severity: cr.severity,
@@ -1527,13 +1563,52 @@ function toRule(cr) {
1527
1563
  impact: cr.impact,
1528
1564
  fix: cr.fix
1529
1565
  },
1530
- check: createPromptBasedCheck()
1566
+ check: createPatternCheck(cr)
1531
1567
  };
1532
1568
  }
1533
- function createPromptBasedCheck(_cr) {
1534
- return (node, _context) => {
1569
+ function createPatternCheck(cr) {
1570
+ return (node, context) => {
1535
1571
  if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
1536
- return null;
1572
+ const match = cr.match;
1573
+ if (match.type && !match.type.includes(node.type)) return null;
1574
+ if (match.notType && match.notType.includes(node.type)) return null;
1575
+ if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
1576
+ if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
1577
+ if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
1578
+ const bbox = node.absoluteBoundingBox;
1579
+ if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
1580
+ if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
1581
+ if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
1582
+ if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
1583
+ if (match.hasAutoLayout === true && !node.layoutMode) return null;
1584
+ if (match.hasAutoLayout === false && node.layoutMode) return null;
1585
+ if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
1586
+ if (match.hasChildren === false && node.children && node.children.length > 0) return null;
1587
+ if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
1588
+ if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
1589
+ if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
1590
+ if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
1591
+ if (match.isInstance === true && node.type !== "INSTANCE") return null;
1592
+ if (match.isInstance === false && node.type === "INSTANCE") return null;
1593
+ if (match.hasComponentId === true && !node.componentId) return null;
1594
+ if (match.hasComponentId === false && node.componentId) return null;
1595
+ if (match.isVisible === true && !node.visible) return null;
1596
+ if (match.isVisible === false && node.visible) return null;
1597
+ if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
1598
+ if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
1599
+ if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
1600
+ if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
1601
+ if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
1602
+ if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
1603
+ if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
1604
+ if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
1605
+ const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
1606
+ return {
1607
+ ruleId: cr.id,
1608
+ nodeId: node.id,
1609
+ nodePath: context.path.join(" > "),
1610
+ message: msg
1611
+ };
1537
1612
  };
1538
1613
  }
1539
1614
  var RuleOverrideSchema = z.object({
@@ -2945,35 +3020,56 @@ function printDocsRules() {
2945
3020
  console.log(`
2946
3021
  CUSTOM RULES GUIDE
2947
3022
 
2948
- Custom rules let you add project-specific checks beyond canicode's built-in 39 rules.
3023
+ Add project-specific checks with declarative pattern matching.
3024
+ All conditions in "match" use AND logic \u2014 every condition must be true to flag a node.
2949
3025
 
2950
- STRUCTURE
2951
- - id: unique identifier (kebab-case)
2952
- - category: layout | token | component | naming | ai-readability | handoff-risk
2953
- - severity: blocking | risk | missing-info | suggestion
2954
- - score: negative number (-1 to -15)
2955
- - prompt: what Claude checks for (used in AI-based evaluation)
2956
- - why: reason this matters
2957
- - impact: consequence if ignored
2958
- - fix: how to resolve
3026
+ MATCH CONDITIONS
3027
+ type: ["FRAME","GROUP"] Node type must be one of these
3028
+ notType: ["INSTANCE"] Node type must NOT be one of these
3029
+ nameContains: "icon" Name contains (case-insensitive)
3030
+ nameNotContains: "badge" Name does NOT contain
3031
+ namePattern: "^btn-" Regex pattern on name
3032
+ minWidth / maxWidth Size constraints (px)
3033
+ minHeight / maxHeight Size constraints (px)
3034
+ hasAutoLayout: true/false Has layoutMode set
3035
+ hasChildren: true/false Has child nodes
3036
+ minChildren / maxChildren Child count range
3037
+ isComponent: true/false Is COMPONENT or COMPONENT_SET
3038
+ isInstance: true/false Is INSTANCE
3039
+ hasComponentId: true/false Has componentId
3040
+ isVisible: true/false Visibility
3041
+ hasFills / hasStrokes Has fills or strokes
3042
+ hasEffects: true/false Has effects
3043
+ minDepth / maxDepth Tree depth range
2959
3044
 
2960
3045
  EXAMPLE
2961
3046
  [
2962
3047
  {
2963
- "id": "icon-missing-component",
3048
+ "id": "icon-not-component",
2964
3049
  "category": "component",
2965
3050
  "severity": "blocking",
2966
3051
  "score": -10,
2967
- "prompt": "Check if this node is an icon (small size, vector children, no text) and is not a component or instance.",
2968
- "why": "Icon nodes that are not components cannot be reused consistently.",
2969
- "impact": "Developers will hardcode icons instead of using a shared component.",
2970
- "fix": "Convert this icon node to a component and publish it to the library."
3052
+ "match": {
3053
+ "type": ["FRAME", "GROUP"],
3054
+ "maxWidth": 48,
3055
+ "maxHeight": 48,
3056
+ "nameContains": "icon"
3057
+ },
3058
+ "message": ""{name}" is an icon but not a component",
3059
+ "why": "Icons should be reusable components.",
3060
+ "impact": "Developers hardcode icons.",
3061
+ "fix": "Convert to component and publish to library."
2971
3062
  }
2972
3063
  ]
2973
3064
 
2974
3065
  USAGE
2975
3066
  canicode analyze <url> --custom-rules ./my-rules.json
2976
- See full example: examples/custom-rules.json
3067
+
3068
+ Full guide: docs/CUSTOMIZATION.md
3069
+ Examples: examples/custom-rules.json
3070
+
3071
+ TIP: Ask any LLM "Write a canicode custom rule that checks X" with the
3072
+ match conditions above. It can generate the JSON for you.
2977
3073
  `.trimStart());
2978
3074
  }
2979
3075
  function printDocsConfig() {
@@ -4807,7 +4903,8 @@ cli.help((sections) => {
4807
4903
  {
4808
4904
  title: "\nCustomization",
4809
4905
  body: [
4810
- ` --config <path> Override rule settings (see: canicode docs config)`
4906
+ ` --config <path> Override rule settings (see: canicode docs config)`,
4907
+ ` --custom-rules <path> Add custom rules (see: canicode docs rules)`
4811
4908
  ].join("\n")
4812
4909
  },
4813
4910
  {