canicode 0.3.1 → 0.3.3

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
@@ -1,6 +1,19 @@
1
- # CanICode
1
+ <p align="center">
2
+ <img src="docs/logo.png" alt="CanICode" width="80">
3
+ </p>
2
4
 
3
- Analyze Figma designs. Score how dev-friendly and AI-friendly they are. Get actionable issues before writing code.
5
+ <h1 align="center">CanICode</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/canicode"><img src="https://img.shields.io/npm/v/canicode.svg" alt="npm version"></a>
9
+ <a href="https://github.com/let-sunny/canicode/actions/workflows/ci.yml"><img src="https://github.com/let-sunny/canicode/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
10
+ <a href="https://github.com/let-sunny/canicode/actions/workflows/release.yml"><img src="https://github.com/let-sunny/canicode/actions/workflows/release.yml/badge.svg" alt="Release"></a>
11
+ <a href="https://let-sunny.github.io/canicode/"><img src="https://img.shields.io/badge/Try_it-GitHub_Pages-blue" alt="GitHub Pages"></a>
12
+ </p>
13
+
14
+ <p align="center">Analyze Figma designs. Score how dev-friendly and AI-friendly they are. Get actionable issues before writing code.</p>
15
+
16
+ <p align="center"><strong><a href="https://let-sunny.github.io/canicode/">Try it in your browser</a></strong> — no install needed.</p>
4
17
 
5
18
  ```bash
6
19
  npm install -g canicode
@@ -27,13 +40,17 @@ canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234"
27
40
 
28
41
  Each issue is classified: **Blocking** > **Risk** > **Missing Info** > **Suggestion**.
29
42
 
30
- Scores use density + diversity weighting per category, combined into an overall grade (A/B/C/D/F).
43
+ Scores use density + diversity weighting per category, combined into an overall grade (S/A+/A/B+/B/C+/C/D/F).
31
44
 
32
45
  ---
33
46
 
34
47
  ## Getting Started
35
48
 
36
- Three ways to use CanICode. Pick one.
49
+ Four ways to use CanICode. Pick one.
50
+
51
+ ### Web (no install)
52
+
53
+ Go to **[let-sunny.github.io/canicode](https://let-sunny.github.io/canicode/)**, paste a Figma URL, and get results instantly in your browser.
37
54
 
38
55
  ### CLI (standalone)
39
56
 
@@ -50,6 +67,8 @@ To enable "Comment on Figma" buttons in reports, set your Figma token:
50
67
  canicode init --token figd_xxxxxxxxxxxxx
51
68
  ```
52
69
 
70
+ > **Get your token:** Figma → Settings → Security → Personal access tokens → Generate new token
71
+
53
72
  ### MCP Server (Claude Code / Cursor / Claude Desktop)
54
73
 
55
74
  **Claude Code:**
@@ -156,7 +175,7 @@ See [`examples/custom-rules.json`](examples/custom-rules.json) | Run `canicode d
156
175
  <details>
157
176
  <summary><strong>Config Overrides</strong></summary>
158
177
 
159
- Override built-in rule scores, severity, and global settings:
178
+ Override rule scores, severity, node exclusions, and global settings:
160
179
 
161
180
  ```bash
162
181
  canicode analyze <url> --config ./my-config.json
@@ -164,8 +183,8 @@ canicode analyze <url> --config ./my-config.json
164
183
 
165
184
  ```json
166
185
  {
186
+ "excludeNodeNames": ["chatbot", "ad-banner", "wip"],
167
187
  "gridBase": 4,
168
- "colorTolerance": 5,
169
188
  "rules": {
170
189
  "no-auto-layout": { "score": -15, "severity": "blocking" },
171
190
  "default-name": { "enabled": false }
@@ -175,7 +194,7 @@ canicode analyze <url> --config ./my-config.json
175
194
 
176
195
  | Option | Description |
177
196
  |--------|-------------|
178
- | `gridBase` | Spacing grid unit (default: 8) |
197
+ | `gridBase` | Spacing grid unit (default: 4) |
179
198
  | `colorTolerance` | Color difference tolerance (default: 10) |
180
199
  | `excludeNodeTypes` | Node types to skip |
181
200
  | `excludeNodeNames` | Node name patterns to skip |
@@ -183,7 +202,7 @@ canicode analyze <url> --config ./my-config.json
183
202
  | `rules.<id>.severity` | Override rule severity |
184
203
  | `rules.<id>.enabled` | Enable/disable a rule |
185
204
 
186
- See [`examples/config.json`](examples/config.json) | Run `canicode docs config`
205
+ See [`examples/config.json`](examples/config.json) | [`docs/CUSTOMIZATION.md`](docs/CUSTOMIZATION.md) | Run `canicode docs config`
187
206
 
188
207
  </details>
189
208
 
@@ -197,7 +216,7 @@ Density Score = 100 - (weighted issue count / node count) × 100
197
216
  Diversity Score = (1 - unique violated rules / total rules in category) × 100
198
217
  ```
199
218
 
200
- Severity weights issues — a single blocking issue counts 3x more than a suggestion. Scores are calculated per category and combined into an overall grade (A/B/C/D/F).
219
+ Severity weights issues — a single blocking issue counts 3x more than a suggestion. Scores are calculated per category and combined into an overall grade (S/A+/A/B+/B/C+/C/D/F).
201
220
 
202
221
  </details>
203
222
 
package/dist/cli/index.js CHANGED
@@ -185,7 +185,7 @@ var RULE_CONFIGS = {
185
185
  score: -2,
186
186
  enabled: true,
187
187
  options: {
188
- gridBase: 8
188
+ gridBase: 4
189
189
  }
190
190
  },
191
191
  "magic-number-spacing": {
@@ -193,7 +193,7 @@ var RULE_CONFIGS = {
193
193
  score: -4,
194
194
  enabled: true,
195
195
  options: {
196
- gridBase: 8
196
+ gridBase: 4
197
197
  }
198
198
  },
199
199
  "raw-shadow": {
@@ -502,11 +502,15 @@ var RuleEngine = class {
502
502
  enabledRuleIds;
503
503
  disabledRuleIds;
504
504
  targetNodeId;
505
+ excludeNamePattern;
506
+ excludeNodeTypes;
505
507
  constructor(options = {}) {
506
508
  this.configs = options.configs ?? RULE_CONFIGS;
507
509
  this.enabledRuleIds = options.enabledRules ? new Set(options.enabledRules) : null;
508
510
  this.disabledRuleIds = new Set(options.disabledRules ?? []);
509
511
  this.targetNodeId = options.targetNodeId;
512
+ this.excludeNamePattern = options.excludeNodeNames && options.excludeNodeNames.length > 0 ? new RegExp(`\\b(${options.excludeNodeNames.join("|")})\\b`, "i") : null;
513
+ this.excludeNodeTypes = options.excludeNodeTypes && options.excludeNodeTypes.length > 0 ? new Set(options.excludeNodeTypes) : null;
510
514
  }
511
515
  /**
512
516
  * Analyze a Figma file and return issues
@@ -560,6 +564,12 @@ var RuleEngine = class {
560
564
  */
561
565
  traverseAndCheck(node, file, rules, maxDepth, issues, depth, path, parent, siblings) {
562
566
  const nodePath = [...path, node.name];
567
+ if (this.excludeNodeTypes && this.excludeNodeTypes.has(node.type)) {
568
+ return;
569
+ }
570
+ if (this.excludeNamePattern && this.excludeNamePattern.test(node.name)) {
571
+ return;
572
+ }
563
573
  const context = {
564
574
  file,
565
575
  parent,
@@ -754,6 +764,12 @@ function transformNode(node) {
754
764
  if ("layoutPositioning" in node && node.layoutPositioning) {
755
765
  base.layoutPositioning = node.layoutPositioning;
756
766
  }
767
+ if ("layoutSizingHorizontal" in node && node.layoutSizingHorizontal) {
768
+ base.layoutSizingHorizontal = node.layoutSizingHorizontal;
769
+ }
770
+ if ("layoutSizingVertical" in node && node.layoutSizingVertical) {
771
+ base.layoutSizingVertical = node.layoutSizingVertical;
772
+ }
757
773
  if ("primaryAxisAlignItems" in node) {
758
774
  base.primaryAxisAlignItems = node.primaryAxisAlignItems;
759
775
  }
@@ -1479,7 +1495,7 @@ function generateHtmlReport(file, result, scores, options) {
1479
1495
  ${CATEGORIES.map((cat) => {
1480
1496
  const cs = scores.byCategory[cat];
1481
1497
  const desc = CATEGORY_DESCRIPTIONS[cat];
1482
- return ` <div class="flex flex-col items-center group relative">
1498
+ return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
1483
1499
  ${renderGaugeSvg(cs.percentage, 100, 7)}
1484
1500
  <span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
1485
1501
  <span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
@@ -1487,7 +1503,7 @@ ${CATEGORIES.map((cat) => {
1487
1503
  ${esc(desc)}
1488
1504
  <div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
1489
1505
  </div>
1490
- </div>`;
1506
+ </a>`;
1491
1507
  }).join("\n")}
1492
1508
  </div>
1493
1509
  </section>
@@ -1542,7 +1558,7 @@ ${figmaToken ? ` <script>
1542
1558
  const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
1543
1559
  method: 'POST',
1544
1560
  headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
1545
- body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId } }),
1561
+ body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
1546
1562
  });
1547
1563
  if (!res.ok) throw new Error(await res.text());
1548
1564
  btn.textContent = 'Sent \\u2713';
@@ -1625,7 +1641,7 @@ function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken)
1625
1641
  for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
1626
1642
  for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
1627
1643
  return `
1628
- <details class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
1644
+ <details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
1629
1645
  <summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
1630
1646
  <span class="inline-flex items-center justify-center w-10 h-6 rounded-md text-xs font-bold border ${scoreBadgeStyle(cs.percentage)}">${cs.percentage}</span>
1631
1647
  <div class="flex-1 min-w-0">
@@ -2305,6 +2321,12 @@ var ActivityLogger = class {
2305
2321
  }
2306
2322
  };
2307
2323
 
2324
+ // src/rules/excluded-names.ts
2325
+ var EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;
2326
+ function isExcludedName(name) {
2327
+ return EXCLUDED_NAME_PATTERN.test(name);
2328
+ }
2329
+
2308
2330
  // src/agents/orchestrator.ts
2309
2331
  function selectNodes(summaries, strategy, maxNodes) {
2310
2332
  if (summaries.length === 0) return [];
@@ -2360,14 +2382,13 @@ var ELIGIBLE_NODE_TYPES = /* @__PURE__ */ new Set([
2360
2382
  "COMPONENT",
2361
2383
  "INSTANCE"
2362
2384
  ]);
2363
- var ICON_NAME_PATTERN = /\b(icon|ico|badge|indicator)\b/i;
2364
2385
  function filterConversionCandidates(summaries, documentRoot) {
2365
2386
  return summaries.filter((summary) => {
2366
2387
  const node = findNode(documentRoot, summary.nodeId);
2367
2388
  if (!node) return false;
2368
2389
  if (EXCLUDED_NODE_TYPES.has(node.type)) return false;
2369
2390
  if (!ELIGIBLE_NODE_TYPES.has(node.type)) return false;
2370
- if (ICON_NAME_PATTERN.test(node.name)) return false;
2391
+ if (isExcludedName(node.name)) return false;
2371
2392
  const bbox = node.absoluteBoundingBox;
2372
2393
  if (bbox && (bbox.width < MIN_WIDTH || bbox.height < MIN_HEIGHT)) return false;
2373
2394
  if (!node.children || node.children.length < 3) return false;
@@ -2752,7 +2773,7 @@ Override canicode's default rule scores, severity, and filters.
2752
2773
  STRUCTURE
2753
2774
  - excludeNodeTypes: node types to skip (e.g. VECTOR, BOOLEAN_OPERATION)
2754
2775
  - excludeNodeNames: name patterns to skip (e.g. icon, ico)
2755
- - gridBase: spacing grid unit, default 8
2776
+ - gridBase: spacing grid unit, default 4
2756
2777
  - colorTolerance: color diff tolerance, default 10
2757
2778
  - rules: per-rule overrides (score, severity, enabled)
2758
2779
 
@@ -2770,7 +2791,9 @@ EXAMPLE
2770
2791
 
2771
2792
  USAGE
2772
2793
  canicode analyze <url> --config ./my-config.json
2773
- See full example: examples/config.json
2794
+
2795
+ Full guide: docs/CUSTOMIZATION.md
2796
+ Examples: examples/config.json
2774
2797
  `.trimStart());
2775
2798
  }
2776
2799
  var DOCS_TOPICS = {
@@ -2836,7 +2859,6 @@ var absolutePositionInAutoLayoutDef = {
2836
2859
  impact: "Element will not respond to sibling changes, may overlap unexpectedly",
2837
2860
  fix: "Remove absolute positioning or use proper Auto Layout alignment"
2838
2861
  };
2839
- var INTENTIONAL_ABSOLUTE_PATTERNS = /^(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|x|icon[-_ ]?(close|dismiss|x)|btn[-_ ]?(close|dismiss))/i;
2840
2862
  function isSmallRelativeToParent(node, parent) {
2841
2863
  const nodeBB = node.absoluteBoundingBox;
2842
2864
  const parentBB = parent.absoluteBoundingBox;
@@ -2850,7 +2872,8 @@ var absolutePositionInAutoLayoutCheck = (node, context) => {
2850
2872
  if (!context.parent) return null;
2851
2873
  if (!hasAutoLayout(context.parent)) return null;
2852
2874
  if (node.layoutPositioning !== "ABSOLUTE") return null;
2853
- if (INTENTIONAL_ABSOLUTE_PATTERNS.test(node.name)) return null;
2875
+ if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null;
2876
+ if (isExcludedName(node.name)) return null;
2854
2877
  if (isSmallRelativeToParent(node, context.parent)) return null;
2855
2878
  if (context.parent.type === "COMPONENT") return null;
2856
2879
  return {
@@ -2876,10 +2899,14 @@ var fixedWidthInResponsiveContextCheck = (node, context) => {
2876
2899
  if (!context.parent) return null;
2877
2900
  if (!hasAutoLayout(context.parent)) return null;
2878
2901
  if (!isContainerNode(node)) return null;
2879
- if (node.layoutAlign === "STRETCH") return null;
2880
- const bbox = node.absoluteBoundingBox;
2881
- if (!bbox) return null;
2882
- if (node.layoutAlign !== "INHERIT") return null;
2902
+ if (node.layoutSizingHorizontal) {
2903
+ if (node.layoutSizingHorizontal !== "FIXED") return null;
2904
+ } else {
2905
+ if (node.layoutAlign === "STRETCH") return null;
2906
+ if (!node.absoluteBoundingBox) return null;
2907
+ if (node.layoutAlign !== "INHERIT") return null;
2908
+ }
2909
+ if (isExcludedName(node.name)) return null;
2883
2910
  return {
2884
2911
  ruleId: fixedWidthInResponsiveContextDef.id,
2885
2912
  nodeId: node.id,
@@ -3152,7 +3179,7 @@ var inconsistentSpacingDef = {
3152
3179
  fix: "Use spacing values from the design system grid (e.g., 8pt increments)"
3153
3180
  };
3154
3181
  var inconsistentSpacingCheck = (node, context, options) => {
3155
- const gridBase = options?.["gridBase"] ?? getRuleOption("inconsistent-spacing", "gridBase", 8);
3182
+ const gridBase = options?.["gridBase"] ?? getRuleOption("inconsistent-spacing", "gridBase", 4);
3156
3183
  const paddings = [
3157
3184
  node.paddingLeft,
3158
3185
  node.paddingRight,
@@ -3194,7 +3221,7 @@ var magicNumberSpacingDef = {
3194
3221
  fix: "Round spacing to the nearest grid value or use spacing tokens"
3195
3222
  };
3196
3223
  var magicNumberSpacingCheck = (node, context, options) => {
3197
- const gridBase = options?.["gridBase"] ?? getRuleOption("magic-number-spacing", "gridBase", 8);
3224
+ const gridBase = options?.["gridBase"] ?? getRuleOption("magic-number-spacing", "gridBase", 4);
3198
3225
  const allSpacings = [
3199
3226
  node.paddingLeft,
3200
3227
  node.paddingRight,
@@ -3511,6 +3538,7 @@ var defaultNameDef = {
3511
3538
  };
3512
3539
  var defaultNameCheck = (node, context) => {
3513
3540
  if (!node.name) return null;
3541
+ if (isExcludedName(node.name)) return null;
3514
3542
  if (!isDefaultName(node.name)) return null;
3515
3543
  return {
3516
3544
  ruleId: defaultNameDef.id,
@@ -3533,6 +3561,7 @@ var nonSemanticNameDef = {
3533
3561
  };
3534
3562
  var nonSemanticNameCheck = (node, context) => {
3535
3563
  if (!node.name) return null;
3564
+ if (isExcludedName(node.name)) return null;
3536
3565
  if (!isNonSemanticName(node.name)) return null;
3537
3566
  if (!node.children || node.children.length === 0) {
3538
3567
  const shapeTypes = ["RECTANGLE", "ELLIPSE", "VECTOR", "LINE", "STAR", "REGULAR_POLYGON"];
@@ -3601,6 +3630,7 @@ var numericSuffixNameDef = {
3601
3630
  };
3602
3631
  var numericSuffixNameCheck = (node, context) => {
3603
3632
  if (!node.name) return null;
3633
+ if (isExcludedName(node.name)) return null;
3604
3634
  if (isDefaultName(node.name)) return null;
3605
3635
  if (!hasNumericSuffix(node.name)) return null;
3606
3636
  return {
@@ -4040,9 +4070,13 @@ Warning: Analyzing ${totalNodes} nodes without scope. Results may be noisy.`);
4040
4070
  Analyzing: ${file.name}`);
4041
4071
  console.log(`Nodes: ${totalNodes}`);
4042
4072
  let configs = options.preset ? { ...getConfigsWithPreset(options.preset) } : { ...RULE_CONFIGS };
4073
+ let excludeNodeNames;
4074
+ let excludeNodeTypes;
4043
4075
  if (options.config) {
4044
4076
  const configFile = await loadConfigFile(options.config);
4045
4077
  configs = mergeConfigs(configs, configFile);
4078
+ excludeNodeNames = configFile.excludeNodeNames;
4079
+ excludeNodeTypes = configFile.excludeNodeTypes;
4046
4080
  console.log(`Config loaded: ${options.config}`);
4047
4081
  }
4048
4082
  if (options.customRules) {
@@ -4055,7 +4089,9 @@ Analyzing: ${file.name}`);
4055
4089
  }
4056
4090
  const analyzeOptions = {
4057
4091
  configs,
4058
- ...effectiveNodeId && { targetNodeId: effectiveNodeId }
4092
+ ...effectiveNodeId && { targetNodeId: effectiveNodeId },
4093
+ ...excludeNodeNames && { excludeNodeNames },
4094
+ ...excludeNodeTypes && { excludeNodeTypes }
4059
4095
  };
4060
4096
  const result = analyzeFile(file, analyzeOptions);
4061
4097
  console.log(`Nodes: ${result.nodeCount} (max depth: ${result.maxDepth})`);
@@ -4346,6 +4382,61 @@ cli.command("init", "Set up canicode (Figma token or MCP)").option("--token <tok
4346
4382
  process.exit(1);
4347
4383
  }
4348
4384
  });
4385
+ cli.command("list-rules", "List all analysis rules with scores and severity").option("--custom-rules <path>", "Include custom rules from JSON file").option("--config <path>", "Apply config overrides to show effective scores").option("--json", "Output as JSON").action(async (options) => {
4386
+ try {
4387
+ let configs = { ...RULE_CONFIGS };
4388
+ if (options.config) {
4389
+ const configFile = await loadConfigFile(options.config);
4390
+ configs = mergeConfigs(configs, configFile);
4391
+ }
4392
+ if (options.customRules) {
4393
+ const { rules: rules2, configs: customConfigs } = await loadCustomRules(options.customRules);
4394
+ for (const rule of rules2) {
4395
+ ruleRegistry.register(rule);
4396
+ }
4397
+ configs = { ...configs, ...customConfigs };
4398
+ }
4399
+ const rules = ruleRegistry.getAll().map((rule) => {
4400
+ const config2 = configs[rule.definition.id];
4401
+ return {
4402
+ id: rule.definition.id,
4403
+ name: rule.definition.name,
4404
+ category: rule.definition.category,
4405
+ severity: config2?.severity ?? "risk",
4406
+ score: config2?.score ?? 0,
4407
+ enabled: config2?.enabled ?? true
4408
+ };
4409
+ });
4410
+ if (options.json) {
4411
+ console.log(JSON.stringify(rules, null, 2));
4412
+ return;
4413
+ }
4414
+ const byCategory = /* @__PURE__ */ new Map();
4415
+ for (const rule of rules) {
4416
+ const list = byCategory.get(rule.category) ?? [];
4417
+ list.push(rule);
4418
+ byCategory.set(rule.category, list);
4419
+ }
4420
+ for (const [category, catRules] of byCategory) {
4421
+ console.log(`
4422
+ ${category.toUpperCase()}`);
4423
+ for (const r of catRules) {
4424
+ const status = r.enabled ? "" : " (disabled)";
4425
+ const pad = " ".repeat(Math.max(0, 40 - r.id.length));
4426
+ console.log(` ${r.id}${pad} ${String(r.score).padStart(4)} ${r.severity}${status}`);
4427
+ }
4428
+ }
4429
+ console.log(`
4430
+ Total: ${rules.length} rules
4431
+ `);
4432
+ } catch (error) {
4433
+ console.error(
4434
+ "\nError:",
4435
+ error instanceof Error ? error.message : String(error)
4436
+ );
4437
+ process.exit(1);
4438
+ }
4439
+ });
4349
4440
  cli.command("docs [topic]", "Show documentation (topics: setup, rules, config)").action((topic) => {
4350
4441
  handleDocs(topic);
4351
4442
  });