canicode 0.12.2 → 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/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { GetFileResponse, Node } from '@figma/rest-api-spec';
3
3
 
4
- var version = "0.12.2";
4
+ var version = "0.12.3";
5
5
 
6
6
  declare const SeveritySchema: z.ZodEnum<{
7
7
  blocking: "blocking";
@@ -480,6 +480,26 @@ interface RuleContext {
480
480
  * sync with `AnalysisNode.type` without a translation layer.
481
481
  */
482
482
  rootNodeType: string;
483
+ /**
484
+ * #557: Root node of the current analysis subtree. Set by
485
+ * `RuleEngine.analyze` to the same node `traverseAndCheck` walks — i.e.
486
+ * `file.document` for full-file analysis or the resolved subtree when
487
+ * `targetNodeId` scopes the run.
488
+ *
489
+ * Distinct from `file.document`, which is **always** the full file root.
490
+ * Rules that build scope-wide indices (e.g. Stage 3 fingerprint pass in
491
+ * `missing-component`) MUST walk `analysisRoot`, not `file.document` —
492
+ * walking the full file under a scoped run produces silent false
493
+ * negatives because the index's first-occurrence id may resolve to a
494
+ * node outside the current scope, and the in-scope duplicate then never
495
+ * matches as "first" so the issue never surfaces.
496
+ *
497
+ * Optional purely so existing rule unit tests that construct
498
+ * `RuleContext` literals do not have to backfill the field. The engine
499
+ * always sets it; rule code reading this should default to
500
+ * `context.file.document` for safety on the unit-test path.
501
+ */
502
+ analysisRoot?: AnalysisNode;
483
503
  /**
484
504
  * ADR-022: lookup canicode-authored acknowledgments by `(nodeId, ruleId)`.
485
505
  * The rule engine builds this from `RuleEngineOptions.acknowledgments` and
@@ -519,6 +539,19 @@ interface RuleViolation {
519
539
  * string keeps lowercase prose.
520
540
  */
521
541
  suggestedName?: string;
542
+ /**
543
+ * Phase 3 (#508 / #560): when a rule emits a single issue that represents
544
+ * a group of N nodes (e.g. `missing-component:structure-repetition` —
545
+ * one issue per fingerprint group covering N qualifying FRAMEs), this
546
+ * lists every node id in the group **including** `nodeId`. Document
547
+ * order, scope-aware. Apply primitives (componentize + replace-with-
548
+ * instance) iterate this list to drive the full componentize+swap loop
549
+ * from a single user answer.
550
+ *
551
+ * Optional: rules that emit one issue per node (the typical path) leave
552
+ * this undefined.
553
+ */
554
+ groupMembers?: string[];
522
555
  }
523
556
  /**
524
557
  * Rule check function signature
@@ -836,6 +869,7 @@ declare const GotchaSurveyQuestionSchema: z.ZodObject<{
836
869
  sourceChildId: z.ZodOptional<z.ZodString>;
837
870
  replicas: z.ZodOptional<z.ZodNumber>;
838
871
  replicaNodeIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
872
+ groupMembers: z.ZodOptional<z.ZodArray<z.ZodString>>;
839
873
  }, z.core.$strip>;
840
874
  type GotchaSurveyQuestion = z.infer<typeof GotchaSurveyQuestionSchema>;
841
875
  /**
@@ -903,6 +937,7 @@ declare const SurveyQuestionBatchSchema: z.ZodObject<{
903
937
  sourceChildId: z.ZodOptional<z.ZodString>;
904
938
  replicas: z.ZodOptional<z.ZodNumber>;
905
939
  replicaNodeIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
940
+ groupMembers: z.ZodOptional<z.ZodArray<z.ZodString>>;
906
941
  }, z.core.$strip>>;
907
942
  totalScenes: z.ZodNumber;
908
943
  }, z.core.$strip>;
@@ -967,6 +1002,7 @@ declare const SurveyQuestionGroupSchema: z.ZodObject<{
967
1002
  sourceChildId: z.ZodOptional<z.ZodString>;
968
1003
  replicas: z.ZodOptional<z.ZodNumber>;
969
1004
  replicaNodeIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
1005
+ groupMembers: z.ZodOptional<z.ZodArray<z.ZodString>>;
970
1006
  }, z.core.$strip>>;
971
1007
  totalScenes: z.ZodNumber;
972
1008
  }, z.core.$strip>>;
@@ -1033,6 +1069,7 @@ declare const GroupedSurveySchema: z.ZodObject<{
1033
1069
  sourceChildId: z.ZodOptional<z.ZodString>;
1034
1070
  replicas: z.ZodOptional<z.ZodNumber>;
1035
1071
  replicaNodeIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
1072
+ groupMembers: z.ZodOptional<z.ZodArray<z.ZodString>>;
1036
1073
  }, z.core.$strip>>;
1037
1074
  totalScenes: z.ZodNumber;
1038
1075
  }, z.core.$strip>>;
@@ -1098,6 +1135,7 @@ declare const GotchaSurveySchema: z.ZodObject<{
1098
1135
  sourceChildId: z.ZodOptional<z.ZodString>;
1099
1136
  replicas: z.ZodOptional<z.ZodNumber>;
1100
1137
  replicaNodeIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
1138
+ groupMembers: z.ZodOptional<z.ZodArray<z.ZodString>>;
1101
1139
  }, z.core.$strip>>;
1102
1140
  groupedQuestions: z.ZodObject<{
1103
1141
  groups: z.ZodArray<z.ZodObject<{
@@ -1160,6 +1198,7 @@ declare const GotchaSurveySchema: z.ZodObject<{
1160
1198
  sourceChildId: z.ZodOptional<z.ZodString>;
1161
1199
  replicas: z.ZodOptional<z.ZodNumber>;
1162
1200
  replicaNodeIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
1201
+ groupMembers: z.ZodOptional<z.ZodArray<z.ZodString>>;
1163
1202
  }, z.core.$strip>>;
1164
1203
  totalScenes: z.ZodNumber;
1165
1204
  }, z.core.$strip>>;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import 'crypto';
6
6
  import { homedir } from 'os';
7
7
 
8
8
  // package.json
9
- var version = "0.12.2";
9
+ var version = "0.12.3";
10
10
  var SeveritySchema = z.enum([
11
11
  "blocking",
12
12
  "risk",
@@ -423,7 +423,25 @@ var GotchaSurveyQuestionSchema = z.object({
423
423
  // `[nodeId, ...replicaNodeIds]` so the same answer lands on every replica.
424
424
  // Single-instance questions omit both fields.
425
425
  replicas: z.number().int().min(2).optional(),
426
- replicaNodeIds: z.array(z.string()).optional()
426
+ replicaNodeIds: z.array(z.string()).optional(),
427
+ /**
428
+ * Phase 3 (#508 / #560 / delta 4a): every node id in the rule's emitted
429
+ * group, including `nodeId`. Currently only populated for
430
+ * `missing-component:structure-repetition` (Stage 3) — one question per
431
+ * fingerprint group, the apply step (delta 4b) iterates this list to
432
+ * componentize the first member and swap the rest.
433
+ *
434
+ * Distinct from `replicas` / `replicaNodeIds`: those collapse N
435
+ * instance-child questions that pre-existed as separate violations into
436
+ * one. `groupMembers` carries N original group members from a single
437
+ * group-shaped violation that never became N separate issues. The apply
438
+ * step distinguishes by which field is set.
439
+ */
440
+ // `.min(2)` locks the contract: a group-shaped emit always carries at
441
+ // least 2 members (Stage 3 short-circuits below `structureMinRepetitions`
442
+ // = 2). A future rule emitting `[]` or `[oneId]` is a programming error,
443
+ // not a runtime case to handle gracefully.
444
+ groupMembers: z.array(z.string()).min(2).optional()
427
445
  });
428
446
  var SurveyQuestionBatchSchema = z.object({
429
447
  ruleId: z.string(),
@@ -995,6 +1013,7 @@ var RuleEngine = class {
995
1013
  analysisState,
996
1014
  scope,
997
1015
  rootNodeType,
1016
+ rootNode,
998
1017
  void 0,
999
1018
  void 0
1000
1019
  );
@@ -1031,7 +1050,7 @@ var RuleEngine = class {
1031
1050
  /**
1032
1051
  * Recursively traverse the tree and run rules
1033
1052
  */
1034
- traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, parent, siblings) {
1053
+ traverseAndCheck(node, file, rules, maxDepth, issues, failedRules, depth, path, ancestorTypes, componentDepth, analysisState, scope, rootNodeType, analysisRoot, parent, siblings) {
1035
1054
  const nodePath = [...path, node.name];
1036
1055
  const isComponentBoundary = node.type === "COMPONENT" || node.type === "COMPONENT_SET" || node.type === "INSTANCE";
1037
1056
  const currentComponentDepth = isComponentBoundary ? 0 : componentDepth;
@@ -1054,6 +1073,7 @@ var RuleEngine = class {
1054
1073
  analysisState,
1055
1074
  scope,
1056
1075
  rootNodeType,
1076
+ analysisRoot,
1057
1077
  findAcknowledgment: (nodeId, ruleId) => acknowledgmentsByKey.get(`${normalizeNodeId(nodeId)}::${ruleId}`)
1058
1078
  };
1059
1079
  for (const rule of rules) {
@@ -1103,6 +1123,7 @@ var RuleEngine = class {
1103
1123
  analysisState,
1104
1124
  scope,
1105
1125
  rootNodeType,
1126
+ analysisRoot,
1106
1127
  node,
1107
1128
  node.children
1108
1129
  );
@@ -2545,8 +2566,8 @@ var missingComponentMsg = {
2545
2566
  message: `"${name}" appears ${count} times`,
2546
2567
  suggestion: `Extract as a reusable component`
2547
2568
  }),
2548
- structureRepetition: (name, siblingCount) => ({
2549
- message: `"${name}" and ${siblingCount} sibling frame(s) share the same internal structure`,
2569
+ structureRepetition: (name, matchCount) => ({
2570
+ message: `"${name}" and ${matchCount} other frame(s) share the same internal structure`,
2550
2571
  suggestion: `Extract a shared component from the repeated structure`
2551
2572
  }),
2552
2573
  styleOverride: (componentName, overrides) => ({
@@ -3281,6 +3302,41 @@ function getSeenStage1(context) {
3281
3302
  function getSeenStage4(context) {
3282
3303
  return getAnalysisState(context, SEEN_STAGE4_KEY, () => /* @__PURE__ */ new Set());
3283
3304
  }
3305
+ function stage3GroupsKey(maxDepth) {
3306
+ return `missing-component:stage3Groups:depth=${maxDepth}`;
3307
+ }
3308
+ function nodeQualifiesForStage3(node, parent, insideInstance) {
3309
+ if (insideInstance) return false;
3310
+ if (node.type !== "FRAME") return false;
3311
+ if (parent?.type === "COMPONENT_SET") return false;
3312
+ if (!node.children || node.children.length === 0) return false;
3313
+ return true;
3314
+ }
3315
+ function buildStage3Groups(root, maxFingerprintDepth) {
3316
+ const groups = /* @__PURE__ */ new Map();
3317
+ const walk = (node, parent, ancestorIsInstance) => {
3318
+ const insideInstance = ancestorIsInstance || node.type === "INSTANCE";
3319
+ if (nodeQualifiesForStage3(node, parent, ancestorIsInstance)) {
3320
+ const fp = buildFingerprint(node, maxFingerprintDepth);
3321
+ const existing = groups.get(fp);
3322
+ if (existing) existing.memberIds.push(node.id);
3323
+ else groups.set(fp, { memberIds: [node.id] });
3324
+ }
3325
+ if (node.children) {
3326
+ for (const child of node.children) walk(child, node, insideInstance);
3327
+ }
3328
+ };
3329
+ walk(root, null, false);
3330
+ return groups;
3331
+ }
3332
+ function getStage3Groups(context, maxFingerprintDepth) {
3333
+ const root = context.analysisRoot ?? context.file.document;
3334
+ return getAnalysisState(
3335
+ context,
3336
+ stage3GroupsKey(maxFingerprintDepth),
3337
+ () => buildStage3Groups(root, maxFingerprintDepth)
3338
+ );
3339
+ }
3284
3340
  var missingComponentDef = {
3285
3341
  id: "missing-component",
3286
3342
  name: "Missing Component",
@@ -3295,7 +3351,8 @@ var missingComponentCheck = (node, context, options) => {
3295
3351
  const matchingComponent = Object.values(components).find(
3296
3352
  (c) => c.name.toLowerCase() === node.name.toLowerCase()
3297
3353
  );
3298
- const frameNames = collectFrameNames(context.file.document);
3354
+ const scopeRoot = context.analysisRoot ?? context.file.document;
3355
+ const frameNames = collectFrameNames(scopeRoot);
3299
3356
  const sameNameFrames = frameNames.get(node.name);
3300
3357
  const firstFrame = sameNameFrames?.[0];
3301
3358
  if (matchingComponent) {
@@ -3323,37 +3380,32 @@ var missingComponentCheck = (node, context, options) => {
3323
3380
  };
3324
3381
  }
3325
3382
  }
3326
- if (isInsideInstance(context)) return null;
3327
- if (context.parent?.type === "COMPONENT_SET") return null;
3328
- if (!node.children || node.children.length === 0) return null;
3383
+ if (!nodeQualifiesForStage3(node, context.parent ?? null, isInsideInstance(context))) {
3384
+ return null;
3385
+ }
3329
3386
  const structureMinRepetitions = options?.["structureMinRepetitions"] ?? getRuleOption("missing-component", "structureMinRepetitions", 2);
3330
3387
  const maxFingerprintDepth = options?.["maxFingerprintDepth"] ?? getRuleOption("missing-component", "maxFingerprintDepth", 3);
3388
+ const groups = getStage3Groups(context, maxFingerprintDepth);
3331
3389
  const fingerprint = buildFingerprint(node, maxFingerprintDepth);
3332
- const siblings = context.siblings ?? [];
3333
- const qualifyingSiblings = siblings.filter(
3334
- (s) => s.type === "FRAME" && s.children !== void 0 && s.children.length > 0
3335
- );
3336
- const matchingNodes = qualifyingSiblings.filter(
3337
- (s) => buildFingerprint(s, maxFingerprintDepth) === fingerprint
3338
- );
3339
- const selfIsInSiblings = qualifyingSiblings.some((s) => s.id === node.id);
3340
- const count = selfIsInSiblings ? matchingNodes.length : matchingNodes.length + 1;
3341
- if (count >= structureMinRepetitions) {
3342
- const firstMatch = qualifyingSiblings.find(
3343
- (s) => buildFingerprint(s, maxFingerprintDepth) === fingerprint
3344
- );
3345
- const firstMatchId = firstMatch?.id ?? node.id;
3346
- if (firstMatchId === node.id) {
3347
- return {
3348
- ruleId: missingComponentDef.id,
3349
- subType: "structure-repetition",
3350
- nodeId: node.id,
3351
- nodePath: context.path.join(" > "),
3352
- ...missingComponentMsg.structureRepetition(node.name, count - 1)
3353
- };
3354
- }
3355
- }
3356
- return null;
3390
+ const group = groups.get(fingerprint);
3391
+ if (!group) return null;
3392
+ if (group.memberIds.length < structureMinRepetitions) return null;
3393
+ if (group.memberIds[0] !== node.id) return null;
3394
+ return {
3395
+ ruleId: missingComponentDef.id,
3396
+ subType: "structure-repetition",
3397
+ nodeId: node.id,
3398
+ nodePath: context.path.join(" > "),
3399
+ ...missingComponentMsg.structureRepetition(
3400
+ node.name,
3401
+ group.memberIds.length - 1
3402
+ ),
3403
+ // #560 / delta 4a: surface the full group so the apply step can drive
3404
+ // the componentize+swap loop from a single user answer. `nodeId` is
3405
+ // the document-order first member; the rest are siblings or cross-
3406
+ // parent matches found by the scope-wide pass (#557).
3407
+ groupMembers: [...group.memberIds]
3408
+ };
3357
3409
  }
3358
3410
  if (node.type === "INSTANCE" && node.componentId) {
3359
3411
  const seenStage4 = getSeenStage4(context);