@usefragments/core 2.0.0 → 2.0.2

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.
@@ -549,6 +549,76 @@ describe("FactIndex — query layer", () => {
549
549
  expect(ix.get(first.id)).toEqual(first);
550
550
  });
551
551
 
552
+ it.each(["missing-first", "missing-second", "moved"] as const)(
553
+ "coalesces component definitions when only their evidence region differs (%s)",
554
+ (mode) => {
555
+ const onConflict = vi.fn();
556
+ const onDuplicate = vi.fn();
557
+ const ix = new FactIndex({ onConflict, onDuplicate });
558
+ const definition = {
559
+ file: "src/Button.tsx",
560
+ exportName: "Button",
561
+ exported: true,
562
+ componentKey: "src/Button.tsx#Button",
563
+ renderRoot: { resolution: "intrinsic", tag: "button" } as const,
564
+ propSurface: ["disabled"],
565
+ };
566
+ const first = makeComponentDefinitionFact({
567
+ ...definition,
568
+ ...(mode !== "missing-first"
569
+ ? { location: { file: definition.file, line: 2, column: 0, endLine: 4, endColumn: 1 } }
570
+ : {}),
571
+ });
572
+ const second = makeComponentDefinitionFact({
573
+ ...definition,
574
+ ...(mode !== "missing-second"
575
+ ? { location: { file: definition.file, line: 6, column: 0, endLine: 8, endColumn: 1 } }
576
+ : {}),
577
+ });
578
+
579
+ ix.addMany([first, second]);
580
+
581
+ expect(onConflict).not.toHaveBeenCalled();
582
+ expect(onDuplicate).toHaveBeenCalledTimes(1);
583
+ expect(onDuplicate).toHaveBeenCalledWith({ kept: first, duplicate: second });
584
+ expect(ix.byKind("component_definition")).toEqual([first]);
585
+ }
586
+ );
587
+
588
+ it.each([
589
+ { renderRoot: { resolution: "intrinsic", tag: "a" } as const },
590
+ { propSurface: ["disabled", "href"] },
591
+ { exported: false },
592
+ ])("retains component-definition conflicts for material changes: %j", (change) => {
593
+ const onConflict = vi.fn();
594
+ const onDuplicate = vi.fn();
595
+ const ix = new FactIndex({ onConflict, onDuplicate });
596
+ const definition = {
597
+ file: "src/Button.tsx",
598
+ exportName: "Button",
599
+ exported: true,
600
+ componentKey: "src/Button.tsx#Button",
601
+ renderRoot: { resolution: "intrinsic", tag: "button" } as const,
602
+ propSurface: ["disabled"],
603
+ };
604
+ const first = makeComponentDefinitionFact(definition);
605
+ const second = makeComponentDefinitionFact({
606
+ ...definition,
607
+ ...change,
608
+ location: { file: definition.file, line: 2, column: 0, endLine: 4, endColumn: 1 },
609
+ });
610
+
611
+ ix.addMany([first, second]);
612
+
613
+ expect(onConflict).toHaveBeenCalledTimes(1);
614
+ expect(onConflict).toHaveBeenCalledWith(expect.stringContaining("conflicting facts"), {
615
+ kept: first,
616
+ skipped: second,
617
+ });
618
+ expect(onDuplicate).not.toHaveBeenCalled();
619
+ expect(ix.byKind("component_definition")).toEqual([first]);
620
+ });
621
+
552
622
  it("includes token-definition provenance in logical conflict reports", () => {
553
623
  const onConflict = vi.fn();
554
624
  const ix = new FactIndex({ onConflict });
@@ -317,6 +317,8 @@ export interface UsageComponentFact extends BaseFact {
317
317
  kind: "usage_component";
318
318
  nodeId: FactId;
319
319
  componentId: ComponentId;
320
+ /** Exact local definition resolved from source, separate from catalog/policy identity. */
321
+ definitionKey?: string;
320
322
  }
321
323
 
322
324
  export interface UsageImportFact extends BaseFact {
@@ -688,6 +690,8 @@ export interface ComponentDefinitionFact extends BaseFact {
688
690
  componentKey: string;
689
691
  renderRoot: ComponentDefinitionRenderRoot;
690
692
  propSurface: string[];
693
+ /** Implementation region, used to scope exemptions; never part of fact identity. */
694
+ location?: FactLocation;
691
695
  stylingChannel?: {
692
696
  module: string;
693
697
  ownsVisualRole: boolean;
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ FactIndex,
4
+ asComponentId,
5
+ makeComponentDefinitionFact,
6
+ makeUsageComponentFact,
7
+ makeUsageNodeFact,
8
+ } from "../index.js";
9
+ import { indexComponentIdentityUsage } from "./usage.js";
10
+
11
+ describe("component identity usage joins", () => {
12
+ const key = "src/Button.tsx#Button";
13
+ const catalogId = asComponentId("cloud:source#source::src/Button.tsx::Button");
14
+
15
+ it("preserves fact identity and counts a node once with exact definition evidence", () => {
16
+ const ix = new FactIndex();
17
+ const node = makeUsageNodeFact({
18
+ file: "src/App.tsx",
19
+ nodePath: "0:0",
20
+ element: "Button",
21
+ location: { file: "src/App.tsx", line: 1, column: 0 },
22
+ });
23
+ const original = makeUsageComponentFact({ nodeId: node.id, componentId: catalogId });
24
+ const resolved = makeUsageComponentFact({
25
+ nodeId: node.id,
26
+ componentId: catalogId,
27
+ definitionKey: key,
28
+ });
29
+ expect(resolved.id).toBe(original.id);
30
+ ix.addMany([
31
+ node,
32
+ resolved,
33
+ makeUsageComponentFact({ nodeId: node.id, componentId: asComponentId(key) }),
34
+ makeComponentDefinitionFact({
35
+ file: "src/Button.tsx",
36
+ exportName: "Button",
37
+ componentKey: key,
38
+ propSurface: [],
39
+ renderRoot: { resolution: "intrinsic", tag: "button" },
40
+ }),
41
+ ]);
42
+ expect(indexComponentIdentityUsage(ix).get(key)).toMatchObject({
43
+ usageCount: 1,
44
+ blastRadius: 1,
45
+ });
46
+ expect(indexComponentIdentityUsage(ix).has(catalogId)).toBe(false);
47
+ });
48
+
49
+ it("does not synthesize a definition from catalog evidence", () => {
50
+ const ix = new FactIndex();
51
+ const node = makeUsageNodeFact({
52
+ file: "src/App.tsx",
53
+ nodePath: "0:0",
54
+ element: "Button",
55
+ location: { file: "src/App.tsx", line: 1, column: 0 },
56
+ });
57
+ ix.addMany([
58
+ node,
59
+ makeUsageComponentFact({ nodeId: node.id, componentId: catalogId, definitionKey: key }),
60
+ ]);
61
+ expect(indexComponentIdentityUsage(ix).has(key)).toBe(false);
62
+ expect(indexComponentIdentityUsage(ix).get(catalogId)?.usageCount).toBe(1);
63
+ });
64
+ });
@@ -12,12 +12,18 @@ export function indexComponentIdentityUsage(
12
12
  ix: FactIndex
13
13
  ): ReadonlyMap<string, ComponentIdentityUsage> {
14
14
  const nodesById = new Map(ix.byKind("usage_node").map((node) => [node.id, node]));
15
+ const definitions = new Set(
16
+ ix.byKind("component_definition").map((definition) => definition.componentKey)
17
+ );
15
18
  const nodesByComponent = new Map<string, Map<string, UsageNodeFact>>();
16
19
 
17
20
  for (const usage of ix.byKind("usage_component")) {
18
21
  const node = nodesById.get(usage.nodeId);
19
22
  if (!node) continue;
20
- const componentKey = String(usage.componentId);
23
+ const componentKey =
24
+ usage.definitionKey && definitions.has(usage.definitionKey)
25
+ ? usage.definitionKey
26
+ : String(usage.componentId);
21
27
  const nodes = nodesByComponent.get(componentKey) ?? new Map<string, UsageNodeFact>();
22
28
  nodes.set(node.id, node);
23
29
  nodesByComponent.set(componentKey, nodes);
package/src/index.ts CHANGED
@@ -684,6 +684,11 @@ export {
684
684
  type RuleConfigView,
685
685
  } from "./rules/rule-config.js";
686
686
  export { indexComponentIdentityUsage, type ComponentIdentityUsage } from "./identity/usage.js";
687
+ export {
688
+ isCanonicalSourceDefinition,
689
+ canonicalSourceContainsFile,
690
+ canonicalSourceIncludesExport,
691
+ } from "./canonical-source.js";
687
692
  export { nearestSignedScaleValue } from "./rules/utils.js";
688
693
  export {
689
694
  buildSpacingTokenLookup,
@@ -1124,6 +1129,7 @@ export {
1124
1129
  EVALUATION_RECEIPT_STRING_MAX_BYTES_V1,
1125
1130
  EVALUATOR_VERSION,
1126
1131
  EVALUATOR_VERSION_V2,
1132
+ LEGACY_EVALUATOR_VERSION_V2,
1127
1133
  effectiveEvaluationWaiverV2Schema,
1128
1134
  evaluate,
1129
1135
  evaluationInputDigestV2,
@@ -2,6 +2,8 @@ import type { CanonicalSource, GovernanceSeverity } from "../governance.js";
2
2
  import type {
3
3
  FactId,
4
4
  FactIndex,
5
+ ComponentDefinitionFact,
6
+ FactLocation,
5
7
  UsageImportFact,
6
8
  UsageNodeFact,
7
9
  UsagePropResolvedFact,
@@ -15,6 +17,8 @@ import {
15
17
  type RawHtmlPrecisionTier,
16
18
  } from "../raw-html-canonical.js";
17
19
  import { ownedImportMatchesRoot, ownedImportsEqual } from "../package-identity-match.js";
20
+ import { indexComponentIdentityUsage } from "../identity/usage.js";
21
+ import { canonicalSourceContainsFile, canonicalSourceIncludesExport } from "../canonical-source.js";
18
22
  import {
19
23
  canonicalDirectionConflictsTarget,
20
24
  projectCanonicalDirectionConflicts,
@@ -262,6 +266,13 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
262
266
  const configuredSources = canonicalSourcesFromPolicy(policy.options?.canonicalSources);
263
267
  const conflicts = projectCanonicalDirectionConflicts(ix, configuredSources);
264
268
  const sources = configuredSources.map((source) => suppressConflictedExports(source, conflicts));
269
+ const approvedLocalDefinitionKeys = Array.isArray(policy.options?.approvedLocalDefinitionKeys)
270
+ ? new Set(
271
+ policy.options.approvedLocalDefinitionKeys.filter(
272
+ (key): key is string => typeof key === "string"
273
+ )
274
+ )
275
+ : undefined;
265
276
  const mappings = canonicalMappingsFromPolicy(policy.options?.canonicalMappings).filter(
266
277
  (mapping) => !canonicalDirectionConflictsTarget(conflicts, mapping.importPath, mapping.name)
267
278
  );
@@ -278,16 +289,41 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
278
289
  // canonical primitives imported by a relative/`@/` path; a browser-safe string
279
290
  // comparison of the raw specifier could never recognize them, so the resolved
280
291
  // fact is the identity of record. Never flag them as import impostors.
281
- const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(ix, sources);
292
+ const canonicalDirectoryResolvedNodes = indexCanonicalDirectoryResolvedNodes(
293
+ ix,
294
+ sources,
295
+ approvedLocalDefinitionKeys
296
+ );
282
297
  const shadowRenderRootNodeIds =
283
298
  ix.policy.ruleConfig("components/shadow-component")?.enabled === true
284
299
  ? indexShadowRenderRootNodeIds(ix)
285
300
  : new Set<FactId>();
286
301
  const findings: Finding[] = [];
287
302
  const seenImportFixes = new Set<string>();
303
+ const canonicalIdentities = new Map(
304
+ ix
305
+ .byKind("component_identity")
306
+ .map((identity) => [identity.componentKey, identity.state === "canonical"])
307
+ );
308
+ const definitionsByFile = new Map<string, ComponentDefinitionFact[]>();
309
+ for (const definition of ix.byKind("component_definition")) {
310
+ const file = normalizePath(definition.file);
311
+ const definitions = definitionsByFile.get(file) ?? [];
312
+ definitions.push(definition);
313
+ definitionsByFile.set(file, definitions);
314
+ }
288
315
 
289
316
  for (const node of ix.byKind("usage_node")) {
290
- if (isCanonicalSourceImplementationFile(node.file, sources)) continue;
317
+ if (
318
+ isCanonicalSourceImplementation(
319
+ node,
320
+ sources,
321
+ definitionsByFile,
322
+ canonicalIdentities,
323
+ approvedLocalDefinitionKeys
324
+ )
325
+ )
326
+ continue;
291
327
  if (node.element.includes(".")) continue;
292
328
  if (canonicalDirectoryResolvedNodes.has(node.id)) continue;
293
329
  if (shadowRenderRootNodeIds.has(node.id)) continue;
@@ -299,7 +335,17 @@ export function ruleComponentsPreferLibrary(ix: FactIndex): Finding[] {
299
335
  node.element === "input" ? readStaticStringProp(props, "type") : undefined;
300
336
  const mapped = findMapping(node, nodeInputType, mappings);
301
337
  if (mapped && imported && isMappedCanonicalImport(imported, mapped)) continue;
302
- if (imported && isCanonicalImport(imported, node.element, sources)) continue;
338
+ if (
339
+ imported &&
340
+ isCanonicalImport(
341
+ imported,
342
+ node.element,
343
+ approvedLocalDefinitionKeys === undefined
344
+ ? sources
345
+ : sources.filter((source) => source.kind !== "directory")
346
+ )
347
+ )
348
+ continue;
303
349
 
304
350
  if (mapped) {
305
351
  if (!shouldSuggestMapped(node, nodeInputType, imported, mapped)) continue;
@@ -1154,22 +1200,34 @@ function canonicalImportPath(source: CanonicalSource): string | undefined {
1154
1200
  */
1155
1201
  function indexCanonicalDirectoryResolvedNodes(
1156
1202
  ix: FactIndex,
1157
- sources: readonly CanonicalSource[]
1203
+ sources: readonly CanonicalSource[],
1204
+ approvedLocalDefinitionKeys?: ReadonlySet<string>
1158
1205
  ): Set<FactId> {
1159
1206
  const out = new Set<FactId>();
1160
- const directoryPaths = sources
1161
- .filter(
1162
- (source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1163
- source.kind === "directory"
1164
- )
1165
- .map((source) => normalizePath(source.path));
1166
- if (directoryPaths.length === 0) return out;
1167
- for (const fact of ix.byKind("usage_component")) {
1168
- const module = componentIdModule(fact.componentId);
1207
+ const directorySources = sources.filter(
1208
+ (source): source is Extract<CanonicalSource, { kind: "directory" }> =>
1209
+ source.kind === "directory"
1210
+ );
1211
+ if (directorySources.length === 0) return out;
1212
+ const identities = new Map(
1213
+ ix.byKind("component_identity").map((identity) => [identity.componentKey, identity])
1214
+ );
1215
+ for (const [componentKey, usage] of indexComponentIdentityUsage(ix)) {
1216
+ if (approvedLocalDefinitionKeys !== undefined && !approvedLocalDefinitionKeys.has(componentKey))
1217
+ continue;
1218
+ const identity = identities.get(componentKey);
1219
+ if (identity && identity.state !== "canonical") continue;
1220
+ const module = componentIdModule(componentKey);
1169
1221
  if (module === undefined) continue;
1170
- const normalizedModule = normalizePath(module);
1171
- if (directoryPaths.some((path) => isPathInSource(normalizedModule, path))) {
1172
- out.add(fact.nodeId);
1222
+ const exportName = componentKey.slice(componentKey.indexOf("#") + 1);
1223
+ if (
1224
+ directorySources.some(
1225
+ (source) =>
1226
+ canonicalSourceContainsFile(source, module) &&
1227
+ canonicalSourceIncludesExport(source, exportName)
1228
+ )
1229
+ ) {
1230
+ for (const node of usage.nodes) out.add(node.id);
1173
1231
  }
1174
1232
  }
1175
1233
  return out;
@@ -1181,28 +1239,47 @@ function componentIdModule(componentId: string): string | undefined {
1181
1239
  return hash > 0 ? componentId.slice(0, hash) : undefined;
1182
1240
  }
1183
1241
 
1184
- function isCanonicalSourceImplementationFile(
1185
- file: string,
1186
- sources: readonly CanonicalSource[]
1242
+ function isCanonicalSourceImplementation(
1243
+ node: UsageNodeFact,
1244
+ sources: readonly CanonicalSource[],
1245
+ definitionsByFile: ReadonlyMap<string, readonly ComponentDefinitionFact[]>,
1246
+ canonicalIdentities: ReadonlyMap<string, boolean>,
1247
+ approvedLocalDefinitionKeys?: ReadonlySet<string>
1187
1248
  ): boolean {
1188
- const normalizedFile = normalizePath(file);
1249
+ const definitions = definitionsByFile.get(normalizePath(node.file)) ?? [];
1189
1250
  return sources.some((source) => {
1190
- if (source.kind === "npm" && source.implementationPath) {
1191
- return isPathInSource(normalizedFile, source.implementationPath);
1192
- }
1193
- if (source.kind === "directory") {
1194
- return isPathInSource(normalizedFile, source.path);
1195
- }
1196
- if (source.kind === "registry") {
1197
- return isPathInSource(normalizedFile, source.installPath);
1198
- }
1199
- return false;
1251
+ if (!canonicalSourceContainsFile(source, node.file)) return false;
1252
+ // Legacy package/registry implementation roots declare file ownership;
1253
+ // their export filters describe the public API. A frozen local inventory
1254
+ // always narrows that ownership to exact approved definition regions.
1255
+ if (
1256
+ approvedLocalDefinitionKeys === undefined &&
1257
+ (source.kind !== "directory" || (source.include === undefined && !source.exclude?.length))
1258
+ )
1259
+ return true;
1260
+ const owners = definitions.filter(
1261
+ (definition) => definition.location && locationContains(definition.location, node.location)
1262
+ );
1263
+ // Unlocated/ambiguous source cannot exempt arbitrary application code.
1264
+ return (
1265
+ owners.length > 0 &&
1266
+ owners.every(
1267
+ (definition) =>
1268
+ (approvedLocalDefinitionKeys === undefined ||
1269
+ approvedLocalDefinitionKeys.has(definition.componentKey)) &&
1270
+ canonicalIdentities.get(definition.componentKey) !== false &&
1271
+ canonicalSourceIncludesExport(source, definition.exportName)
1272
+ )
1273
+ );
1200
1274
  });
1201
1275
  }
1202
1276
 
1203
- function isPathInSource(file: string, sourcePath: string): boolean {
1204
- const normalizedSource = normalizePath(sourcePath).replace(/\/$/, "");
1205
- return file === normalizedSource || file.startsWith(`${normalizedSource}/`);
1277
+ function locationContains(region: FactLocation, node: FactLocation): boolean {
1278
+ if (region.endLine === undefined || region.endColumn === undefined) return false;
1279
+ return (
1280
+ (node.line > region.line || (node.line === region.line && node.column >= region.column)) &&
1281
+ (node.line < region.endLine || (node.line === region.endLine && node.column < region.endColumn))
1282
+ );
1206
1283
  }
1207
1284
 
1208
1285
  function normalizePath(path: string): string {