canicode 0.12.1 → 0.12.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/dist/cli/index.js CHANGED
@@ -1408,7 +1408,24 @@ var EVENTS = {
1408
1408
  // has a single place to wire it up.
1409
1409
  ROUNDTRIP_DEFINITION_WRITE_SKIPPED: `${EVENT_PREFIX}roundtrip_definition_write_skipped`,
1410
1410
  /** CLI `canicode roundtrip-tally` completed successfully. */
1411
- ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`
1411
+ ROUNDTRIP_TALLY: `${EVENT_PREFIX}roundtrip_tally`,
1412
+ /**
1413
+ * Phase 3 (#508 / ADR-023): `applyComponentize` outcome — either a
1414
+ * successful `createComponentFromNode` call or one of the guard rejections
1415
+ * (instance-child, free-form parent, error) that route to the Strategy C
1416
+ * annotate-fallback. Surfaces under `props.outcome` so a Node-side reader
1417
+ * can split the funnel.
1418
+ */
1419
+ ROUNDTRIP_COMPONENTIZE: `${EVENT_PREFIX}roundtrip_componentize`,
1420
+ /**
1421
+ * Phase 3 (#508 / ADR-023, #554): `applyReplaceWithInstance` outcome —
1422
+ * either a successful instance swap (`outcome: "replaced"`) or one of the
1423
+ * guard rejections (`"skipped-free-form-parent"`, `"skipped-prereq-missing"`,
1424
+ * `"error"`). The primitive pairs with `ROUNDTRIP_COMPONENTIZE` so a
1425
+ * Node-side reader can correlate componentize+swap pairs across a single
1426
+ * Phase 3 batch.
1427
+ */
1428
+ ROUNDTRIP_REPLACE_WITH_INSTANCE: `${EVENT_PREFIX}roundtrip_replace_with_instance`
1412
1429
  };
1413
1430
 
1414
1431
  // src/core/monitoring/capture.ts
@@ -2515,8 +2532,8 @@ var missingComponentMsg = {
2515
2532
  message: `"${name}" appears ${count} times`,
2516
2533
  suggestion: `Extract as a reusable component`
2517
2534
  }),
2518
- structureRepetition: (name, siblingCount) => ({
2519
- message: `"${name}" and ${siblingCount} sibling frame(s) share the same internal structure`,
2535
+ structureRepetition: (name, matchCount) => ({
2536
+ message: `"${name}" and ${matchCount} other frame(s) share the same internal structure`,
2520
2537
  suggestion: `Extract a shared component from the repeated structure`
2521
2538
  }),
2522
2539
  styleOverride: (componentName, overrides) => ({
@@ -3297,6 +3314,41 @@ function getSeenStage1(context) {
3297
3314
  function getSeenStage4(context) {
3298
3315
  return getAnalysisState(context, SEEN_STAGE4_KEY, () => /* @__PURE__ */ new Set());
3299
3316
  }
3317
+ function stage3GroupsKey(maxDepth) {
3318
+ return `missing-component:stage3Groups:depth=${maxDepth}`;
3319
+ }
3320
+ function nodeQualifiesForStage3(node, parent, insideInstance) {
3321
+ if (insideInstance) return false;
3322
+ if (node.type !== "FRAME") return false;
3323
+ if (parent?.type === "COMPONENT_SET") return false;
3324
+ if (!node.children || node.children.length === 0) return false;
3325
+ return true;
3326
+ }
3327
+ function buildStage3Groups(root, maxFingerprintDepth) {
3328
+ const groups = /* @__PURE__ */ new Map();
3329
+ const walk = (node, parent, ancestorIsInstance) => {
3330
+ const insideInstance = ancestorIsInstance || node.type === "INSTANCE";
3331
+ if (nodeQualifiesForStage3(node, parent, ancestorIsInstance)) {
3332
+ const fp = buildFingerprint(node, maxFingerprintDepth);
3333
+ const existing = groups.get(fp);
3334
+ if (existing) existing.memberIds.push(node.id);
3335
+ else groups.set(fp, { memberIds: [node.id] });
3336
+ }
3337
+ if (node.children) {
3338
+ for (const child of node.children) walk(child, node, insideInstance);
3339
+ }
3340
+ };
3341
+ walk(root, null, false);
3342
+ return groups;
3343
+ }
3344
+ function getStage3Groups(context, maxFingerprintDepth) {
3345
+ const root = context.analysisRoot ?? context.file.document;
3346
+ return getAnalysisState(
3347
+ context,
3348
+ stage3GroupsKey(maxFingerprintDepth),
3349
+ () => buildStage3Groups(root, maxFingerprintDepth)
3350
+ );
3351
+ }
3300
3352
  var missingComponentDef = {
3301
3353
  id: "missing-component",
3302
3354
  name: "Missing Component",
@@ -3311,7 +3363,8 @@ var missingComponentCheck = (node, context, options) => {
3311
3363
  const matchingComponent = Object.values(components).find(
3312
3364
  (c) => c.name.toLowerCase() === node.name.toLowerCase()
3313
3365
  );
3314
- const frameNames = collectFrameNames(context.file.document);
3366
+ const scopeRoot = context.analysisRoot ?? context.file.document;
3367
+ const frameNames = collectFrameNames(scopeRoot);
3315
3368
  const sameNameFrames = frameNames.get(node.name);
3316
3369
  const firstFrame = sameNameFrames?.[0];
3317
3370
  if (matchingComponent) {
@@ -3339,37 +3392,32 @@ var missingComponentCheck = (node, context, options) => {
3339
3392
  };
3340
3393
  }
3341
3394
  }
3342
- if (isInsideInstance(context)) return null;
3343
- if (context.parent?.type === "COMPONENT_SET") return null;
3344
- if (!node.children || node.children.length === 0) return null;
3395
+ if (!nodeQualifiesForStage3(node, context.parent ?? null, isInsideInstance(context))) {
3396
+ return null;
3397
+ }
3345
3398
  const structureMinRepetitions = options?.["structureMinRepetitions"] ?? getRuleOption("missing-component", "structureMinRepetitions", 2);
3346
3399
  const maxFingerprintDepth = options?.["maxFingerprintDepth"] ?? getRuleOption("missing-component", "maxFingerprintDepth", 3);
3400
+ const groups = getStage3Groups(context, maxFingerprintDepth);
3347
3401
  const fingerprint = buildFingerprint(node, maxFingerprintDepth);
3348
- const siblings = context.siblings ?? [];
3349
- const qualifyingSiblings = siblings.filter(
3350
- (s) => s.type === "FRAME" && s.children !== void 0 && s.children.length > 0
3351
- );
3352
- const matchingNodes = qualifyingSiblings.filter(
3353
- (s) => buildFingerprint(s, maxFingerprintDepth) === fingerprint
3354
- );
3355
- const selfIsInSiblings = qualifyingSiblings.some((s) => s.id === node.id);
3356
- const count = selfIsInSiblings ? matchingNodes.length : matchingNodes.length + 1;
3357
- if (count >= structureMinRepetitions) {
3358
- const firstMatch = qualifyingSiblings.find(
3359
- (s) => buildFingerprint(s, maxFingerprintDepth) === fingerprint
3360
- );
3361
- const firstMatchId = firstMatch?.id ?? node.id;
3362
- if (firstMatchId === node.id) {
3363
- return {
3364
- ruleId: missingComponentDef.id,
3365
- subType: "structure-repetition",
3366
- nodeId: node.id,
3367
- nodePath: context.path.join(" > "),
3368
- ...missingComponentMsg.structureRepetition(node.name, count - 1)
3369
- };
3370
- }
3371
- }
3372
- return null;
3402
+ const group = groups.get(fingerprint);
3403
+ if (!group) return null;
3404
+ if (group.memberIds.length < structureMinRepetitions) return null;
3405
+ if (group.memberIds[0] !== node.id) return null;
3406
+ return {
3407
+ ruleId: missingComponentDef.id,
3408
+ subType: "structure-repetition",
3409
+ nodeId: node.id,
3410
+ nodePath: context.path.join(" > "),
3411
+ ...missingComponentMsg.structureRepetition(
3412
+ node.name,
3413
+ group.memberIds.length - 1
3414
+ ),
3415
+ // #560 / delta 4a: surface the full group so the apply step can drive
3416
+ // the componentize+swap loop from a single user answer. `nodeId` is
3417
+ // the document-order first member; the rest are siblings or cross-
3418
+ // parent matches found by the scope-wide pass (#557).
3419
+ groupMembers: [...group.memberIds]
3420
+ };
3373
3421
  }
3374
3422
  if (node.type === "INSTANCE" && node.componentId) {
3375
3423
  const seenStage4 = getSeenStage4(context);
@@ -3458,6 +3506,7 @@ defineRule({
3458
3506
  });
3459
3507
  var CODE_CONNECT_SETUP_KEY = "unmapped-component:setup-detected";
3460
3508
  var CODE_CONNECT_MAPPINGS_KEY = "unmapped-component:mappings";
3509
+ var SEEN_MAIN_IDS_KEY = "unmapped-component:seen-main-ids";
3461
3510
  function codeConnectIsSetUp(context) {
3462
3511
  return getAnalysisState(context, CODE_CONNECT_SETUP_KEY, () => {
3463
3512
  return existsSync(join(process.cwd(), "figma.config.json"));
@@ -3470,6 +3519,9 @@ function codeConnectMappings(context) {
3470
3519
  () => parseCodeConnectMappings(process.cwd())
3471
3520
  );
3472
3521
  }
3522
+ function seenMainIds(context) {
3523
+ return getAnalysisState(context, SEEN_MAIN_IDS_KEY, () => /* @__PURE__ */ new Set());
3524
+ }
3473
3525
  var unmappedComponentDef = {
3474
3526
  id: "unmapped-component",
3475
3527
  name: "Unmapped Component",
@@ -3479,20 +3531,33 @@ var unmappedComponentDef = {
3479
3531
  fix: "Run /canicode-roundtrip on this component to register a mapping. Figma's get_code_connect_map will skip if a mapping already exists."
3480
3532
  };
3481
3533
  var unmappedComponentCheck = (node, context) => {
3482
- if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
3483
- if (isInsideInstance(context)) return null;
3484
3534
  if (!codeConnectIsSetUp(context)) return null;
3535
+ let mainId = null;
3536
+ let mainName = node.name;
3537
+ if (node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
3538
+ if (isInsideInstance(context)) return null;
3539
+ mainId = node.id;
3540
+ } else if (node.type === "INSTANCE" && node.componentId) {
3541
+ mainId = node.componentId;
3542
+ const meta = context.file.components[node.componentId];
3543
+ if (meta?.name) mainName = meta.name;
3544
+ } else {
3545
+ return null;
3546
+ }
3547
+ const seen = seenMainIds(context);
3548
+ if (seen.has(mainId)) return null;
3549
+ seen.add(mainId);
3485
3550
  const mappings = codeConnectMappings(context);
3486
- if (mappings.mappedNodeIds.has(node.id)) return null;
3487
- const ack = context.findAcknowledgment(node.id, unmappedComponentDef.id);
3551
+ if (mappings.mappedNodeIds.has(mainId)) return null;
3552
+ const ack = context.findAcknowledgment(mainId, unmappedComponentDef.id);
3488
3553
  if (ack && isRuleOptOutIntent(ack.intent) && ack.intent.ruleId === unmappedComponentDef.id) {
3489
3554
  return null;
3490
3555
  }
3491
3556
  return {
3492
3557
  ruleId: unmappedComponentDef.id,
3493
- nodeId: node.id,
3558
+ nodeId: mainId,
3494
3559
  nodePath: context.path.join(" > "),
3495
- ...unmappedComponentMsg(node.name)
3560
+ ...unmappedComponentMsg(mainName)
3496
3561
  };
3497
3562
  };
3498
3563
  defineRule({
@@ -3966,6 +4031,7 @@ var RuleEngine = class {
3966
4031
  analysisState,
3967
4032
  scope,
3968
4033
  rootNodeType,
4034
+ rootNode,
3969
4035
  void 0,
3970
4036
  void 0
3971
4037
  );
@@ -4002,7 +4068,7 @@ var RuleEngine = class {
4002
4068
  /**
4003
4069
  * Recursively traverse the tree and run rules
4004
4070
  */
4005
- traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, parent, siblings) {
4071
+ traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, analysisRoot, parent, siblings) {
4006
4072
  const nodePath = [...path, node.name];
4007
4073
  const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
4008
4074
  const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
@@ -4025,6 +4091,7 @@ var RuleEngine = class {
4025
4091
  analysisState,
4026
4092
  scope,
4027
4093
  rootNodeType,
4094
+ analysisRoot,
4028
4095
  findAcknowledgment: (nodeId, ruleId) => acknowledgmentsByKey.get(`${normalizeNodeId(nodeId)}::${ruleId}`)
4029
4096
  };
4030
4097
  for (const rule of rules) {
@@ -4074,6 +4141,7 @@ var RuleEngine = class {
4074
4141
  analysisState,
4075
4142
  scope,
4076
4143
  rootNodeType,
4144
+ analysisRoot,
4077
4145
  node,
4078
4146
  node.children
4079
4147
  );
@@ -4509,7 +4577,7 @@ function computeApplyContext(violation, instanceContext) {
4509
4577
  }
4510
4578
 
4511
4579
  // package.json
4512
- var version2 = "0.12.1";
4580
+ var version2 = "0.12.3";
4513
4581
 
4514
4582
  // src/core/engine/scoring.ts
4515
4583
  var GRADE_ORDER = ["S", "A+", "A", "B+", "B", "C+", "C", "D", "F"];
@@ -6535,7 +6603,11 @@ function mapToQuestion(issue, file) {
6535
6603
  ...applyContext.annotationProperties !== void 0 ? { annotationProperties: applyContext.annotationProperties } : {},
6536
6604
  ...suggestedName !== void 0 ? { suggestedName } : {},
6537
6605
  isInstanceChild: applyContext.isInstanceChild,
6538
- ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {}
6606
+ ...applyContext.sourceChildId !== void 0 ? { sourceChildId: applyContext.sourceChildId } : {},
6607
+ // #560 / Phase 3 delta 4a: thread groupMembers through from the
6608
+ // violation. Currently only populated by `missing-component`
6609
+ // Stage 3; non-group rules pass undefined and the field is omitted.
6610
+ ...issue.violation.groupMembers !== void 0 ? { groupMembers: issue.violation.groupMembers } : {}
6539
6611
  };
6540
6612
  }
6541
6613
  function deduplicateBySourceComponent(questions) {
@@ -6771,7 +6843,25 @@ var GotchaSurveyQuestionSchema = z.object({
6771
6843
  // `[nodeId, ...replicaNodeIds]` so the same answer lands on every replica.
6772
6844
  // Single-instance questions omit both fields.
6773
6845
  replicas: z.number().int().min(2).optional(),
6774
- replicaNodeIds: z.array(z.string()).optional()
6846
+ replicaNodeIds: z.array(z.string()).optional(),
6847
+ /**
6848
+ * Phase 3 (#508 / #560 / delta 4a): every node id in the rule's emitted
6849
+ * group, including `nodeId`. Currently only populated for
6850
+ * `missing-component:structure-repetition` (Stage 3) — one question per
6851
+ * fingerprint group, the apply step (delta 4b) iterates this list to
6852
+ * componentize the first member and swap the rest.
6853
+ *
6854
+ * Distinct from `replicas` / `replicaNodeIds`: those collapse N
6855
+ * instance-child questions that pre-existed as separate violations into
6856
+ * one. `groupMembers` carries N original group members from a single
6857
+ * group-shaped violation that never became N separate issues. The apply
6858
+ * step distinguishes by which field is set.
6859
+ */
6860
+ // `.min(2)` locks the contract: a group-shaped emit always carries at
6861
+ // least 2 members (Stage 3 short-circuits below `structureMinRepetitions`
6862
+ // = 2). A future rule emitting `[]` or `[oneId]` is a programming error,
6863
+ // not a runtime case to handle gracefully.
6864
+ groupMembers: z.array(z.string()).min(2).optional()
6775
6865
  });
6776
6866
  var SurveyQuestionBatchSchema = z.object({
6777
6867
  ruleId: z.string(),
@@ -7945,7 +8035,7 @@ function runCodeConnectChecks(cwd) {
7945
8035
  }
7946
8036
  var PUBLISH_CHECK_NAME = "Figma component published in a library";
7947
8037
  async function runFigmaPublishCheck(input) {
7948
- const { figmaUrl, token, fetchPublishedComponents } = input;
8038
+ const { figmaUrl, token, fetchPublishedComponents, fetchNodeType } = input;
7949
8039
  let parsed;
7950
8040
  try {
7951
8041
  parsed = parseFigmaUrl(figmaUrl);
@@ -8011,6 +8101,23 @@ async function runFigmaPublishCheck(input) {
8011
8101
  detail: `${match.name} (${match.node_id})`
8012
8102
  };
8013
8103
  }
8104
+ if (fetchNodeType) {
8105
+ let nodeType;
8106
+ try {
8107
+ nodeType = await fetchNodeType(parsed.fileKey, canonicalNodeId);
8108
+ } catch {
8109
+ nodeType = void 0;
8110
+ }
8111
+ if (nodeType && nodeType !== "COMPONENT" && nodeType !== "COMPONENT_SET") {
8112
+ return {
8113
+ name: PUBLISH_CHECK_NAME,
8114
+ pass: false,
8115
+ inconclusive: true,
8116
+ detail: `node ${canonicalNodeId} is type ${nodeType} \u2014 Code Connect mapping is per-component`,
8117
+ remediation: "Step 7 (Code Connect close-out) skips on screen-level scope anyway. To verify a specific component, re-invoke doctor with that component's URL."
8118
+ };
8119
+ }
8120
+ }
8014
8121
  return {
8015
8122
  name: PUBLISH_CHECK_NAME,
8016
8123
  pass: false,
@@ -8058,7 +8165,11 @@ function registerDoctor(cli2) {
8058
8165
  const publishCheck = await runFigmaPublishCheck({
8059
8166
  figmaUrl: options.figmaUrl,
8060
8167
  token,
8061
- fetchPublishedComponents: client ? (fileKey) => client.getPublishedComponents(fileKey) : void 0
8168
+ fetchPublishedComponents: client ? (fileKey) => client.getPublishedComponents(fileKey) : void 0,
8169
+ fetchNodeType: client ? async (fileKey, nodeId) => {
8170
+ const response = await client.getFileNodes(fileKey, [nodeId]);
8171
+ return response.nodes[nodeId]?.document.type;
8172
+ } : void 0
8062
8173
  });
8063
8174
  results.push(publishCheck);
8064
8175
  }