@usefragments/core 1.8.0 → 1.9.0

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.
@@ -1,3 +1,3 @@
1
1
  import 'zod';
2
2
  export { LegacySeverityLevel, Severity, SeverityLevel, legacySeverityLevelSchema, severityLevelSchema, severitySchema } from '../severity.js';
3
- export { b as AGENT_FORMAT_SCHEMA_VERSION, c as AgentErrorEnvelope, A as AgentFormat, d as AgentOutput, Q as FactEvidence, R as FactLocation, F as Finding, a as FindingFix, f as FindingReplaceClassTokenFix, g as FindingReplaceComponentFix, h as FindingReplaceImportFix, i as FindingReplacePropValueFix, j as FindingReplaceStyleValueFix, k as Fix, G as GovernanceVerdict, l as GovernanceVerdictMetadata, S as SuppressionDirective, V as ValidatorResult, n as Violation, o as agentErrorEnvelopeSchema, T as agentFindingSchema, p as agentFormatSchema, q as agentIntegritySchema, r as agentOutputSchema, U as agentPlanSchema, t as factEvidenceSchema, u as factLocationSchema, v as findingFixSchema, w as findingReplaceClassTokenFixSchema, x as findingReplaceComponentFixSchema, y as findingReplaceImportFixSchema, z as findingReplacePropValueFixSchema, B as findingReplaceStyleValueFixSchema, C as findingSchema, D as fixSchema, H as governanceVerdictMetadataSchema, J as governanceVerdictSchema, K as normalizeFinding, L as normalizeSeverity, M as normalizeViolation, N as suppressionDirectiveSchema, O as validatorResultSchema, P as violationSchema } from '../index-Cxk3SOQP.js';
3
+ export { b as AGENT_FORMAT_SCHEMA_VERSION, c as AgentErrorEnvelope, A as AgentFormat, d as AgentOutput, Q as FactEvidence, R as FactLocation, F as Finding, a as FindingFix, f as FindingReplaceClassTokenFix, g as FindingReplaceComponentFix, h as FindingReplaceImportFix, i as FindingReplacePropValueFix, j as FindingReplaceStyleValueFix, k as Fix, G as GovernanceVerdict, l as GovernanceVerdictMetadata, S as SuppressionDirective, V as ValidatorResult, n as Violation, o as agentErrorEnvelopeSchema, T as agentFindingSchema, p as agentFormatSchema, q as agentIntegritySchema, r as agentOutputSchema, U as agentPlanSchema, t as factEvidenceSchema, u as factLocationSchema, v as findingFixSchema, w as findingReplaceClassTokenFixSchema, x as findingReplaceComponentFixSchema, y as findingReplaceImportFixSchema, z as findingReplacePropValueFixSchema, B as findingReplaceStyleValueFixSchema, C as findingSchema, D as fixSchema, H as governanceVerdictMetadataSchema, J as governanceVerdictSchema, K as normalizeFinding, L as normalizeSeverity, M as normalizeViolation, N as suppressionDirectiveSchema, O as validatorResultSchema, P as violationSchema } from '../index-DtHxs0Pf.js';
@@ -24,7 +24,7 @@ import {
24
24
  suppressionDirectiveSchema,
25
25
  validatorResultSchema,
26
26
  violationSchema
27
- } from "../chunk-UUREQ4HD.js";
27
+ } from "../chunk-DGHZQTLH.js";
28
28
  import "../chunk-JNBFJ34I.js";
29
29
  import "../chunk-EIYNNS77.js";
30
30
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefragments/core",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "license": "MIT",
5
5
  "description": "Core types, schemas, and runtime API for Fragments component definitions",
6
6
  "author": "Conan McNicholl",
@@ -28,17 +28,38 @@ export interface MakeFindingInput {
28
28
  evidence: FactEvidence[];
29
29
  evidenceGrade?: EvidenceGrade;
30
30
  fingerprintIdentity: Record<string, unknown>;
31
+ /**
32
+ * Legacy identity tuple from before a fingerprint migration. When present,
33
+ * the finding also carries `previousFingerprint` (the legacy hash) so
34
+ * baseline classification can match records keyed on the old hash during
35
+ * the grace window. Position keys are permitted here — legacy identities
36
+ * are reproduced verbatim, contract violations included.
37
+ */
38
+ previousFingerprintIdentity?: Record<string, unknown>;
31
39
  fix?: FindingFix;
32
40
  attributes?: Record<string, unknown>;
33
41
  }
34
42
 
43
+ const POSITION_IDENTITY_KEYS = ["line", "column", "endLine", "endColumn", "offset", "location"];
44
+
35
45
  export function makeFinding(input: MakeFindingInput): Finding {
36
46
  if (input.evidence.length === 0) {
37
47
  throw new Error(`makeFinding(${input.ruleId}): findings must carry at least one evidence fact`);
38
48
  }
49
+ const positionKeys = POSITION_IDENTITY_KEYS.filter((key) => key in input.fingerprintIdentity);
50
+ if (positionKeys.length > 0) {
51
+ throw new Error(
52
+ `makeFinding(${input.ruleId}): fingerprintIdentity must not contain position keys ` +
53
+ `(${positionKeys.join(", ")}) — fingerprints hash what's wrong, never where it sits. ` +
54
+ `Use a source-order occurrence index for repeats of the same identity.`
55
+ );
56
+ }
39
57
  const fingerprint = hash64Hex(
40
58
  canonicalJson({ ruleId: input.ruleId, ...input.fingerprintIdentity })
41
59
  );
60
+ const previousFingerprint = input.previousFingerprintIdentity
61
+ ? hash64Hex(canonicalJson({ ruleId: input.ruleId, ...input.previousFingerprintIdentity }))
62
+ : undefined;
42
63
  const code = byRuleId.get(input.ruleId);
43
64
  return normalizeFinding({
44
65
  ruleId: input.ruleId,
@@ -48,6 +69,7 @@ export function makeFinding(input: MakeFindingInput): Finding {
48
69
  helpUrl: code?.explainUrl,
49
70
  message: input.message,
50
71
  fingerprint,
72
+ ...(previousFingerprint !== undefined ? { previousFingerprint } : {}),
51
73
  location: input.location,
52
74
  evidence: input.evidence,
53
75
  evidenceGrade: input.evidenceGrade ?? "source_backed",
@@ -13,6 +13,12 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
13
13
 
14
14
  const findings: Finding[] = [];
15
15
  const seen = new Set<string>();
16
+ // Source-order occurrence index per identity tuple: the fingerprint contract
17
+ // (finding.ts) forbids position, so repeats of the same offending import in
18
+ // one file are distinguished by ordinal, not line/column. Deleting the first
19
+ // occurrence promotes the second to its index — debt count stays truthful
20
+ // while edits elsewhere in the file cannot re-key anything.
21
+ const occurrences = new Map<string, number>();
16
22
 
17
23
  for (const usage of ix.byKind("usage_import")) {
18
24
  // A confirmed bridge supersedes any non-bridge preference over the same
@@ -41,6 +47,9 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
41
47
  const key = `${policy.id}\0${usage.file}\0${usage.source}\0${usage.location.line}\0${usage.location.column}\0${importedIdentity}`;
42
48
  if (seen.has(key)) continue;
43
49
  seen.add(key);
50
+ const identityKey = `${usage.file}\0${usage.source}\0${policy.to}\0${importedIdentity}`;
51
+ const occurrence = occurrences.get(identityKey) ?? 0;
52
+ occurrences.set(identityKey, occurrence + 1);
44
53
 
45
54
  findings.push(
46
55
  makeFinding({
@@ -53,6 +62,15 @@ export function ruleJsxPreferredImportPath(ix: FactIndex): Finding[] {
53
62
  location: usage.location,
54
63
  evidence: ix.evidence([usage.id, policy.id]),
55
64
  fingerprintIdentity: {
65
+ file: usage.file,
66
+ from: usage.source,
67
+ to: policy.to,
68
+ imported: policy.imported,
69
+ occurrence,
70
+ },
71
+ // Grace window (baseline v1 → v2): the pre-migration hash keyed on
72
+ // line/column. Drop with the v1 dual-match after one release.
73
+ previousFingerprintIdentity: {
56
74
  file: usage.file,
57
75
  from: usage.source,
58
76
  to: policy.to,
@@ -37,6 +37,7 @@ import {
37
37
  } from "../index.js";
38
38
  import type { CanonicalBridgeV1, Fact, FactId } from "../index.js";
39
39
  import { RULE_VERSION as A11Y_REQUIRED_ACCESSIBLE_NAME_VERSION } from "./a11y-required-accessible-name.js";
40
+ import { makeFinding } from "./finding.js";
40
41
 
41
42
  type ButtonProps = {
42
43
  variant?: "primary" | "secondary" | "ghost" | "link";
@@ -438,6 +439,78 @@ describe("preferred JSX imports and components", () => {
438
439
  });
439
440
  });
440
441
 
442
+ it("keeps preferred-path fingerprints stable across line drift", () => {
443
+ const findingsAtLine = (line: number) => {
444
+ const ix = new FactIndex();
445
+ ix.addMany(
446
+ compileGlobalGovernanceFacts({
447
+ jsx: [g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui", { severity: "error" })],
448
+ })
449
+ );
450
+ ix.add(
451
+ makeUsageImportFact({
452
+ file: "apps/checkout/page.tsx",
453
+ local: "Button",
454
+ imported: "Button",
455
+ source: "@legacy/ui",
456
+ location: { file: "apps/checkout/page.tsx", line, column: 0 },
457
+ })
458
+ );
459
+ return ruleJsxPreferredImportPath(ix);
460
+ };
461
+
462
+ const before = findingsAtLine(1);
463
+ const after = findingsAtLine(9);
464
+
465
+ expect(after[0].fingerprint).toBe(before[0].fingerprint);
466
+ // Byte-pinned hashes: the legacy value is what a released v1 CLI recorded
467
+ // for this exact tuple (hash64Hex(canonicalJson({ruleId, file, from, to,
468
+ // line, column}))). If previousFingerprintIdentity ever drifts from the
469
+ // true v1 formula, real baselines mass-invalidate — this literal is the
470
+ // tripwire.
471
+ expect(before[0].fingerprint).toBe("4deee0ff7bb1810b");
472
+ expect(before[0].previousFingerprint).toBe("c04344ad7a96b651");
473
+ // The grace-window legacy hash still tracks position, so a v1 baseline
474
+ // recorded at either line matches exactly one of the two runs.
475
+ expect(after[0].previousFingerprint).not.toBe(before[0].previousFingerprint);
476
+ });
477
+
478
+ it("distinguishes repeated same-identity imports by source-order occurrence", () => {
479
+ // Two imports of the same specifier under different local aliases share the
480
+ // fingerprint identity tuple (local never enters the hash) — the ordinal
481
+ // tells them apart. Byte-identical duplicates collapse at the fact layer.
482
+ const findingsFor = (locals: readonly string[]) => {
483
+ const ix = new FactIndex();
484
+ ix.addMany(
485
+ compileGlobalGovernanceFacts({
486
+ jsx: [g.jsx.importPath().prefer("@legacy/ui", "@usefragments/ui", { severity: "error" })],
487
+ })
488
+ );
489
+ locals.forEach((local, index) => {
490
+ ix.add(
491
+ makeUsageImportFact({
492
+ file: "apps/checkout/page.tsx",
493
+ local,
494
+ imported: "Button",
495
+ source: "@legacy/ui",
496
+ location: { file: "apps/checkout/page.tsx", line: index * 10 + 1, column: 0 },
497
+ })
498
+ );
499
+ });
500
+ return ruleJsxPreferredImportPath(ix);
501
+ };
502
+
503
+ const both = findingsFor(["Button", "AliasedButton"]);
504
+ expect(both).toHaveLength(2);
505
+ expect(both[0].fingerprint).not.toBe(both[1].fingerprint);
506
+
507
+ // Deleting the first occurrence promotes the survivor to its ordinal, so
508
+ // the remaining debt matches a fingerprint the baseline already accepts.
509
+ const survivor = findingsFor(["AliasedButton"]);
510
+ expect(survivor).toHaveLength(1);
511
+ expect(survivor[0].fingerprint).toBe(both[0].fingerprint);
512
+ });
513
+
441
514
  it("emits a deterministic component replacement for canonical component mappings", () => {
442
515
  const legacyButtonId = makeComponentId("@legacy/ui", "LegacyButton");
443
516
  const ix = new FactIndex();
@@ -4163,6 +4236,47 @@ describe("styles/no-raw-dimensions", () => {
4163
4236
  });
4164
4237
  });
4165
4238
 
4239
+ // ---------------------------------------------------------------------------
4240
+ // makeFinding fingerprint contract
4241
+ // ---------------------------------------------------------------------------
4242
+
4243
+ describe("makeFinding fingerprint contract", () => {
4244
+ const base = {
4245
+ ruleId: "styles/no-raw-color",
4246
+ ruleVersion: "1",
4247
+ severity: "serious" as const,
4248
+ message: "Raw color",
4249
+ location: { file: "src/App.tsx", line: 3, column: 1 },
4250
+ evidence: [{ factId: "fact-1" as FactId, fact: { kind: "style_declaration" } }],
4251
+ };
4252
+
4253
+ it("rejects position keys in fingerprintIdentity", () => {
4254
+ expect(() =>
4255
+ makeFinding({
4256
+ ...base,
4257
+ fingerprintIdentity: { file: "src/App.tsx", value: "#fff", line: 3, column: 1 },
4258
+ })
4259
+ ).toThrow(/position keys \(line, column\)/);
4260
+ });
4261
+
4262
+ it("hashes a legacy identity into previousFingerprint when provided", () => {
4263
+ const migrated = makeFinding({
4264
+ ...base,
4265
+ fingerprintIdentity: { file: "src/App.tsx", value: "#fff", occurrence: 0 },
4266
+ previousFingerprintIdentity: { file: "src/App.tsx", value: "#fff", line: 3, column: 1 },
4267
+ });
4268
+ const plain = makeFinding({
4269
+ ...base,
4270
+ fingerprintIdentity: { file: "src/App.tsx", value: "#fff", occurrence: 0 },
4271
+ });
4272
+
4273
+ expect(migrated.fingerprint).toBe(plain.fingerprint);
4274
+ expect(migrated.previousFingerprint).toBeDefined();
4275
+ expect(migrated.previousFingerprint).not.toBe(migrated.fingerprint);
4276
+ expect(plain.previousFingerprint).toBeUndefined();
4277
+ });
4278
+ });
4279
+
4166
4280
  // Type witness so eslint/tsc don't trip on unused imports above.
4167
4281
  const _typeWitness: Fact[] = [];
4168
4282
  void _typeWitness;
@@ -116,6 +116,12 @@ export const findingSchema = z.object({
116
116
  helpUrl: z.string().url().optional(),
117
117
  message: z.string(),
118
118
  fingerprint: z.string(),
119
+ /**
120
+ * Fingerprint this finding carried before a fingerprint-identity migration.
121
+ * Emitted only by migrated rules during the grace window so baseline
122
+ * records keyed on the legacy hash keep matching instead of churning.
123
+ */
124
+ previousFingerprint: z.string().optional(),
119
125
  location: factLocationSchema,
120
126
  evidence: z.array(factEvidenceSchema).min(1),
121
127
  evidenceGrade: evidenceGradeSchema.optional(),