@usefragments/core 2.0.0 → 2.0.1

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.
@@ -24,7 +24,7 @@ import {
24
24
  suppressionDirectiveSchema,
25
25
  validatorResultSchema,
26
26
  violationSchema
27
- } from "../chunk-BMPYIUZE.js";
27
+ } from "../chunk-QPOKQ5H6.js";
28
28
  import "../chunk-EIYNNS77.js";
29
29
  import "../chunk-PWIJMOI4.js";
30
30
  import "../chunk-JNBFJ34I.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
@@ -0,0 +1,41 @@
1
+ import type { CanonicalSource } from "./governance.js";
2
+
3
+ /** Export filters narrow source ownership; an empty include approves nothing. */
4
+ export function canonicalSourceIncludesExport(source: CanonicalSource, name: string): boolean {
5
+ const role = name.split(".").at(-1) ?? name;
6
+ const matches = (names: readonly string[]) => names.includes(name) || names.includes(role);
7
+ return (
8
+ !matches(source.exclude ?? []) && (source.include === undefined || matches(source.include))
9
+ );
10
+ }
11
+
12
+ /** Filesystem ownership only. Package import specifiers are not local paths. */
13
+ export function canonicalSourceContainsFile(source: CanonicalSource, file: string): boolean {
14
+ const root =
15
+ source.kind === "directory"
16
+ ? source.path
17
+ : source.kind === "registry"
18
+ ? source.installPath
19
+ : source.implementationPath;
20
+ if (!root) return false;
21
+ const normalize = (value: string) =>
22
+ value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
23
+ const normalizedRoot = normalize(root);
24
+ const normalizedFile = normalize(file);
25
+ return (
26
+ normalizedRoot === "." ||
27
+ normalizedFile === normalizedRoot ||
28
+ normalizedFile.startsWith(`${normalizedRoot}/`)
29
+ );
30
+ }
31
+
32
+ export function isCanonicalSourceDefinition(
33
+ file: string,
34
+ exportName: string,
35
+ sources: readonly CanonicalSource[]
36
+ ): boolean {
37
+ return sources.some(
38
+ (source) =>
39
+ canonicalSourceContainsFile(source, file) && canonicalSourceIncludesExport(source, exportName)
40
+ );
41
+ }
@@ -122,6 +122,7 @@ export function makeComponentDefinitionFact(input: {
122
122
  componentKey: string;
123
123
  renderRoot: ComponentDefinitionFact["renderRoot"];
124
124
  propSurface: string[];
125
+ location?: FactLocation;
125
126
  stylingChannel?: ComponentDefinitionFact["stylingChannel"];
126
127
  }): ComponentDefinitionFact {
127
128
  return {
@@ -134,6 +135,7 @@ export function makeComponentDefinitionFact(input: {
134
135
  componentKey: input.componentKey,
135
136
  renderRoot: input.renderRoot,
136
137
  propSurface: [...input.propSurface],
138
+ ...(input.location ? { location: { ...input.location } } : {}),
137
139
  ...(input.stylingChannel ? { stylingChannel: { ...input.stylingChannel } } : {}),
138
140
  };
139
141
  }
@@ -664,6 +666,7 @@ export function makeUsageNodeFact(input: {
664
666
  export function makeUsageComponentFact(input: {
665
667
  nodeId: FactId;
666
668
  componentId: ComponentId;
669
+ definitionKey?: string;
667
670
  }): UsageComponentFact {
668
671
  return {
669
672
  id: factId("usage_component", {
@@ -673,6 +676,7 @@ export function makeUsageComponentFact(input: {
673
676
  kind: "usage_component",
674
677
  nodeId: input.nodeId,
675
678
  componentId: input.componentId,
679
+ ...(input.definitionKey ? { definitionKey: input.definitionKey } : {}),
676
680
  };
677
681
  }
678
682
 
@@ -87,6 +87,10 @@ function logicalFactForComparison(fact: Fact): Record<string, unknown> {
87
87
  const { location: _location, sourceNames: _sourceNames, ...logicalFact } = fact;
88
88
  return logicalFact;
89
89
  }
90
+ if (fact.kind === "component_definition") {
91
+ const { location: _location, ...logicalFact } = fact;
92
+ return { ...logicalFact, componentId: logicalComponentId(fact.componentId) };
93
+ }
90
94
  if (fact.kind === "jsx_import_path_preferred") {
91
95
  return {
92
96
  ...fact,
@@ -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,
@@ -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 {