@trailmark/core 0.1.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.
Files changed (94) hide show
  1. package/dist/baseline.d.ts +21 -0
  2. package/dist/baseline.d.ts.map +1 -0
  3. package/dist/baseline.js +187 -0
  4. package/dist/baseline.js.map +1 -0
  5. package/dist/diff.d.ts +3 -0
  6. package/dist/diff.d.ts.map +1 -0
  7. package/dist/diff.js +120 -0
  8. package/dist/diff.js.map +1 -0
  9. package/dist/fingerprint.d.ts +8 -0
  10. package/dist/fingerprint.d.ts.map +1 -0
  11. package/dist/fingerprint.js +85 -0
  12. package/dist/fingerprint.js.map +1 -0
  13. package/dist/hash.d.ts +4 -0
  14. package/dist/hash.d.ts.map +1 -0
  15. package/dist/hash.js +29 -0
  16. package/dist/hash.js.map +1 -0
  17. package/dist/impact.d.ts +5 -0
  18. package/dist/impact.d.ts.map +1 -0
  19. package/dist/impact.js +27 -0
  20. package/dist/impact.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +28 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/issue.d.ts +10 -0
  26. package/dist/issue.d.ts.map +1 -0
  27. package/dist/issue.js +40 -0
  28. package/dist/issue.js.map +1 -0
  29. package/dist/normalize.d.ts +3 -0
  30. package/dist/normalize.d.ts.map +1 -0
  31. package/dist/normalize.js +59 -0
  32. package/dist/normalize.js.map +1 -0
  33. package/dist/report/buildReport.d.ts +3 -0
  34. package/dist/report/buildReport.d.ts.map +1 -0
  35. package/dist/report/buildReport.js +223 -0
  36. package/dist/report/buildReport.js.map +1 -0
  37. package/dist/report/formatConsole.d.ts +3 -0
  38. package/dist/report/formatConsole.d.ts.map +1 -0
  39. package/dist/report/formatConsole.js +112 -0
  40. package/dist/report/formatConsole.js.map +1 -0
  41. package/dist/report/index.d.ts +4 -0
  42. package/dist/report/index.d.ts.map +1 -0
  43. package/dist/report/index.js +20 -0
  44. package/dist/report/index.js.map +1 -0
  45. package/dist/report/sort.d.ts +9 -0
  46. package/dist/report/sort.d.ts.map +1 -0
  47. package/dist/report/sort.js +20 -0
  48. package/dist/report/sort.js.map +1 -0
  49. package/dist/report/types.d.ts +85 -0
  50. package/dist/report/types.d.ts.map +1 -0
  51. package/dist/report/types.js +3 -0
  52. package/dist/report/types.js.map +1 -0
  53. package/dist/run.d.ts +8 -0
  54. package/dist/run.d.ts.map +1 -0
  55. package/dist/run.js +20 -0
  56. package/dist/run.js.map +1 -0
  57. package/dist/scope.d.ts +5 -0
  58. package/dist/scope.d.ts.map +1 -0
  59. package/dist/scope.js +23 -0
  60. package/dist/scope.js.map +1 -0
  61. package/dist/types.d.ts +150 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +3 -0
  64. package/dist/types.js.map +1 -0
  65. package/dist/url.d.ts +3 -0
  66. package/dist/url.d.ts.map +1 -0
  67. package/dist/url.js +82 -0
  68. package/dist/url.js.map +1 -0
  69. package/package.json +23 -0
  70. package/src/baseline.ts +217 -0
  71. package/src/diff.ts +150 -0
  72. package/src/fingerprint.ts +107 -0
  73. package/src/hash.ts +29 -0
  74. package/src/impact.ts +27 -0
  75. package/src/index.ts +11 -0
  76. package/src/issue.ts +49 -0
  77. package/src/normalize.ts +66 -0
  78. package/src/report/buildReport.ts +276 -0
  79. package/src/report/formatConsole.ts +137 -0
  80. package/src/report/index.ts +3 -0
  81. package/src/report/sort.ts +28 -0
  82. package/src/report/types.ts +101 -0
  83. package/src/run.ts +22 -0
  84. package/src/scope.ts +25 -0
  85. package/src/types.ts +167 -0
  86. package/src/url.ts +102 -0
  87. package/test/baseline.test.ts +151 -0
  88. package/test/diff.test.ts +113 -0
  89. package/test/fingerprint.test.ts +41 -0
  90. package/test/issue.test.ts +79 -0
  91. package/test/report.build.test.ts +191 -0
  92. package/test/report.console.test.ts +102 -0
  93. package/test/url.test.ts +25 -0
  94. package/tsconfig.json +8 -0
package/src/diff.ts ADDED
@@ -0,0 +1,150 @@
1
+ import {
2
+ BaselineEntry,
3
+ BaselineFile,
4
+ DiffOptions,
5
+ DiffResult,
6
+ TrailmarkFinding,
7
+ TrailmarkRun,
8
+ } from "./types"
9
+ import { scopedIssueKey, scopeKeyFromBaselineEntry, scopeKeyFromMeta } from "./scope"
10
+ import { impactKey } from "./impact"
11
+
12
+ function dedupeFindings(findings: TrailmarkFinding[]): TrailmarkFinding[] {
13
+ const unique = new Map<string, TrailmarkFinding>()
14
+ for (const finding of findings) {
15
+ const key = scopedIssueKey(finding.issueId, scopeKeyFromMeta(finding.meta))
16
+ if (!unique.has(key)) {
17
+ unique.set(key, finding)
18
+ }
19
+ }
20
+
21
+ return Array.from(unique.entries())
22
+ .sort(([left], [right]) => left.localeCompare(right))
23
+ .map(([, finding]) => finding)
24
+ }
25
+
26
+ function makeRelaxedMap(baselineEntries: BaselineEntry[]): Map<string, BaselineEntry[]> {
27
+ const map = new Map<string, BaselineEntry[]>()
28
+ for (const entry of baselineEntries) {
29
+ const key = scopedIssueKey(entry.relaxedIssueId, scopeKeyFromBaselineEntry(entry))
30
+ const list = map.get(key) ?? []
31
+ list.push(entry)
32
+ map.set(key, list)
33
+ }
34
+ return map
35
+ }
36
+
37
+ function makeExactKey(entry: BaselineEntry): string {
38
+ return scopedIssueKey(entry.issueId, scopeKeyFromBaselineEntry(entry))
39
+ }
40
+
41
+ function pickUnmatched(
42
+ list: BaselineEntry[] | undefined,
43
+ matchedBaselineIds: Set<string>,
44
+ ): BaselineEntry | undefined {
45
+ if (!list || list.length === 0) {
46
+ return undefined
47
+ }
48
+
49
+ return list.find((entry) => !matchedBaselineIds.has(makeExactKey(entry)))
50
+ }
51
+
52
+ export function diffRuns(
53
+ baseline: BaselineFile,
54
+ current: TrailmarkRun,
55
+ options: DiffOptions = {},
56
+ ): DiffResult {
57
+ const debug = options.onDebug
58
+ const failOnImpact = new Set(
59
+ (options.failOnImpact ?? ["serious", "critical"]).map((value) => value.toLowerCase()),
60
+ )
61
+ const baselineEntries = Object.values(baseline.findings)
62
+ const baselineByIssueId = new Map(baselineEntries.map((entry) => [makeExactKey(entry), entry]))
63
+ const baselineByRelaxed = makeRelaxedMap(baselineEntries)
64
+
65
+ const newFindings: TrailmarkFinding[] = []
66
+ const existing: DiffResult["existing"] = []
67
+ const matchedBaselineIds = new Set<string>()
68
+
69
+ for (const finding of dedupeFindings(current.findings)) {
70
+ const scopeKey = scopeKeyFromMeta(finding.meta)
71
+ const scopedFindingIssueKey = scopedIssueKey(finding.issueId, scopeKey)
72
+ const exact = baselineByIssueId.get(scopedFindingIssueKey)
73
+
74
+ if (exact) {
75
+ const baselineKey = makeExactKey(exact)
76
+ existing.push({
77
+ finding,
78
+ matchedBy: "exact",
79
+ baselineIssueId: exact.issueId,
80
+ baselineScopeKey: scopeKeyFromBaselineEntry(exact),
81
+ })
82
+ matchedBaselineIds.add(baselineKey)
83
+ debug?.("exact-match", {
84
+ findingIssueId: finding.issueId,
85
+ findingScopeKey: scopeKey,
86
+ baselineIssueId: exact.issueId,
87
+ baselineScopeKey: scopeKeyFromBaselineEntry(exact),
88
+ })
89
+ continue
90
+ }
91
+
92
+ const relaxedMatches = baselineByRelaxed.get(scopedIssueKey(finding.relaxedIssueId, scopeKey))
93
+ const relaxed = pickUnmatched(relaxedMatches, matchedBaselineIds)
94
+
95
+ if (relaxed) {
96
+ const baselineKey = makeExactKey(relaxed)
97
+ existing.push({
98
+ finding,
99
+ matchedBy: "relaxed",
100
+ baselineIssueId: relaxed.issueId,
101
+ baselineScopeKey: scopeKeyFromBaselineEntry(relaxed),
102
+ })
103
+ matchedBaselineIds.add(baselineKey)
104
+ debug?.("relaxed-match", {
105
+ findingIssueId: finding.issueId,
106
+ findingScopeKey: scopeKey,
107
+ baselineIssueId: relaxed.issueId,
108
+ baselineScopeKey: scopeKeyFromBaselineEntry(relaxed),
109
+ })
110
+ continue
111
+ }
112
+
113
+ newFindings.push(finding)
114
+ debug?.("new-finding", {
115
+ findingIssueId: finding.issueId,
116
+ findingScopeKey: scopeKey,
117
+ })
118
+ }
119
+
120
+ const resolved = baselineEntries.filter((entry) => !matchedBaselineIds.has(makeExactKey(entry)))
121
+ for (const entry of resolved) {
122
+ debug?.("resolved-finding", {
123
+ baselineIssueId: entry.issueId,
124
+ baselineScopeKey: scopeKeyFromBaselineEntry(entry),
125
+ })
126
+ }
127
+
128
+ const newByImpact: Record<string, number> = {}
129
+ for (const finding of newFindings) {
130
+ const key = impactKey(finding.impact)
131
+ newByImpact[key] = (newByImpact[key] ?? 0) + 1
132
+ }
133
+
134
+ const blockingNew = newFindings.filter((finding) => failOnImpact.has(impactKey(finding.impact)))
135
+
136
+ return {
137
+ newFindings,
138
+ existing,
139
+ resolved,
140
+ blockingNew,
141
+ stats: {
142
+ totalCurrent: dedupeFindings(current.findings).length,
143
+ totalBaseline: baselineEntries.length,
144
+ newCount: newFindings.length,
145
+ existingCount: existing.length,
146
+ resolvedCount: resolved.length,
147
+ newByImpact,
148
+ },
149
+ }
150
+ }
@@ -0,0 +1,107 @@
1
+ import { hashObject } from "./hash"
2
+ import { AxeNodeResult, TargetFingerprintCanonical } from "./types"
3
+
4
+ const STABLE_ATTR_NAMES = [
5
+ "data-testid",
6
+ "data-test",
7
+ "data-qa",
8
+ "aria-label",
9
+ "aria-labelledby",
10
+ "id",
11
+ "name",
12
+ "role",
13
+ "type",
14
+ "href",
15
+ "for",
16
+ ] as const
17
+
18
+ const STABLE_ATTR_SET = new Set<string>(STABLE_ATTR_NAMES)
19
+
20
+ function truncate(value: string, maxLength = 120): string {
21
+ if (value.length <= maxLength) {
22
+ return value
23
+ }
24
+ return value.slice(0, maxLength)
25
+ }
26
+
27
+ function parseTag(html: string | undefined): string | null {
28
+ if (!html) {
29
+ return null
30
+ }
31
+
32
+ const match = html.match(/^\s*<\s*([a-zA-Z0-9:-]+)/)
33
+ return match ? match[1].toLowerCase() : null
34
+ }
35
+
36
+ function parseStableAttrs(html: string | undefined): Record<string, string> {
37
+ if (!html) {
38
+ return {}
39
+ }
40
+
41
+ const attrs: Record<string, string> = {}
42
+ const attrRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
43
+
44
+ for (const match of html.matchAll(attrRegex)) {
45
+ const rawName = match[1]
46
+ const name = rawName.toLowerCase()
47
+ if (!STABLE_ATTR_SET.has(name)) {
48
+ continue
49
+ }
50
+
51
+ const value = match[3] ?? match[4] ?? match[5] ?? ""
52
+ attrs[name] = truncate(value.trim())
53
+ }
54
+
55
+ return attrs
56
+ }
57
+
58
+ function sanitizeSelectorHint(selector: string): string {
59
+ let output = selector
60
+ .replace(/:nth-[a-z-]+\([^)]*\)/gi, "")
61
+ .replace(/\[(\w[\w-]*)\s*=\s*["']?\d{2,}["']?\s*\]/g, "[$1]")
62
+ .replace(/\b\d{2,}\b/g, "")
63
+ .replace(/\s+/g, " ")
64
+ .trim()
65
+ return truncate(output, 120)
66
+ }
67
+
68
+ function selectorHintFromTarget(target: string[] | undefined): string | null {
69
+ const first = target?.[0]
70
+ if (!first) {
71
+ return null
72
+ }
73
+
74
+ const chunks = first
75
+ .split(/\s+|>|\+|~/)
76
+ .map((part) => part.trim())
77
+ .filter(Boolean)
78
+
79
+ const last = chunks.length > 0 ? chunks[chunks.length - 1] : first
80
+ const hint = sanitizeSelectorHint(last)
81
+ return hint || null
82
+ }
83
+
84
+ export interface TargetFingerprintResult {
85
+ canonical: TargetFingerprintCanonical
86
+ targetHash: string
87
+ selectorHint: string | null
88
+ }
89
+
90
+ export function buildTargetFingerprint(
91
+ node: Pick<AxeNodeResult, "target" | "html">,
92
+ ): TargetFingerprintResult {
93
+ const selectorHint = selectorHintFromTarget(node.target)
94
+ const stableAttrs = parseStableAttrs(node.html)
95
+
96
+ const canonical: TargetFingerprintCanonical = {
97
+ tag: parseTag(node.html),
98
+ stableAttrs,
99
+ structuralHint: Object.keys(stableAttrs).length === 0 ? selectorHint : null,
100
+ }
101
+
102
+ return {
103
+ canonical,
104
+ selectorHint,
105
+ targetHash: hashObject(canonical),
106
+ }
107
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ function sortValue(value: unknown): unknown {
4
+ if (Array.isArray(value)) {
5
+ return value.map((item) => sortValue(item))
6
+ }
7
+
8
+ if (value && typeof value === "object") {
9
+ const sorted: Record<string, unknown> = {}
10
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
11
+ sorted[key] = sortValue((value as Record<string, unknown>)[key])
12
+ }
13
+ return sorted
14
+ }
15
+
16
+ return value
17
+ }
18
+
19
+ export function stableStringify(value: unknown): string {
20
+ return JSON.stringify(sortValue(value))
21
+ }
22
+
23
+ export function sha1Hex(input: string): string {
24
+ return createHash("sha1").update(input).digest("hex")
25
+ }
26
+
27
+ export function hashObject(input: unknown): string {
28
+ return sha1Hex(stableStringify(input))
29
+ }
package/src/impact.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { TrailmarkImpact, TrailmarkImpactKey } from "./types"
2
+
3
+ export const IMPACT_ORDER: TrailmarkImpactKey[] = [
4
+ "critical",
5
+ "serious",
6
+ "moderate",
7
+ "minor",
8
+ "unknown",
9
+ ]
10
+
11
+ export function impactKey(impact: TrailmarkImpact | TrailmarkImpactKey): TrailmarkImpactKey {
12
+ if (!impact) {
13
+ return "unknown"
14
+ }
15
+
16
+ return impact
17
+ }
18
+
19
+ export function impactRank(impact: TrailmarkImpact | TrailmarkImpactKey): number {
20
+ const key = impactKey(impact)
21
+ const index = IMPACT_ORDER.indexOf(key)
22
+ if (index >= 0) {
23
+ return index
24
+ }
25
+
26
+ return IMPACT_ORDER.length + 1
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./types"
2
+ export * from "./hash"
3
+ export * from "./url"
4
+ export * from "./issue"
5
+ export * from "./fingerprint"
6
+ export * from "./normalize"
7
+ export * from "./run"
8
+ export * from "./baseline"
9
+ export * from "./scope"
10
+ export * from "./diff"
11
+ export * from "./report"
package/src/issue.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { hashObject } from "./hash"
2
+
3
+ export interface IssueIdInput {
4
+ ruleId: string
5
+ pageKey: string
6
+ targetHash: string
7
+ failureHint?: string
8
+ }
9
+
10
+ export function normalizeFailureHint(summary: string | undefined): string | undefined {
11
+ if (!summary) {
12
+ return undefined
13
+ }
14
+
15
+ const normalized = summary
16
+ .toLowerCase()
17
+ .replace(/["']/g, "")
18
+ .replace(/\d+/g, "#")
19
+ .replace(/\s+/g, " ")
20
+ .trim()
21
+
22
+ if (!normalized) {
23
+ return undefined
24
+ }
25
+
26
+ return normalized
27
+ }
28
+
29
+ export function buildRelaxedIssueId(input: Omit<IssueIdInput, "failureHint">): string {
30
+ return hashObject({
31
+ ruleId: input.ruleId,
32
+ pageKey: input.pageKey,
33
+ targetHash: input.targetHash,
34
+ })
35
+ }
36
+
37
+ export function buildIssueId(input: IssueIdInput): string {
38
+ const payload: Record<string, string> = {
39
+ ruleId: input.ruleId,
40
+ pageKey: input.pageKey,
41
+ targetHash: input.targetHash,
42
+ }
43
+
44
+ if (input.failureHint) {
45
+ payload.failureHint = input.failureHint
46
+ }
47
+
48
+ return hashObject(payload)
49
+ }
@@ -0,0 +1,66 @@
1
+ import { buildTargetFingerprint } from "./fingerprint"
2
+ import { buildIssueId, buildRelaxedIssueId, normalizeFailureHint } from "./issue"
3
+ import { normalizeUrlToPageKey } from "./url"
4
+ import { NormalizeInput, TrailmarkFinding } from "./types"
5
+
6
+ function truncate(value: string | undefined, maxLength: number): string | undefined {
7
+ if (!value) {
8
+ return undefined
9
+ }
10
+
11
+ if (value.length <= maxLength) {
12
+ return value
13
+ }
14
+
15
+ return value.slice(0, maxLength)
16
+ }
17
+
18
+ export function normalizeAxeResults(input: NormalizeInput): TrailmarkFinding[] {
19
+ const pageKey = normalizeUrlToPageKey(input.pageUrl, input.urlOptions)
20
+ const htmlMaxLength = input.htmlMaxLength ?? 400
21
+ const failureSummaryMaxLength = input.failureSummaryMaxLength ?? 400
22
+
23
+ const findings: TrailmarkFinding[] = []
24
+
25
+ for (const violation of input.results.violations ?? []) {
26
+ for (const node of violation.nodes ?? []) {
27
+ const fingerprint = buildTargetFingerprint(node)
28
+ const failureSummary = truncate(node.failureSummary, failureSummaryMaxLength)
29
+ const failureHint = normalizeFailureHint(failureSummary)
30
+
31
+ const relaxedIssueId = buildRelaxedIssueId({
32
+ ruleId: violation.id,
33
+ pageKey,
34
+ targetHash: fingerprint.targetHash,
35
+ })
36
+
37
+ const issueId = buildIssueId({
38
+ ruleId: violation.id,
39
+ pageKey,
40
+ targetHash: fingerprint.targetHash,
41
+ failureHint,
42
+ })
43
+
44
+ findings.push({
45
+ issueId,
46
+ relaxedIssueId,
47
+ ruleId: violation.id,
48
+ impact: violation.impact,
49
+ tags: [...(violation.tags ?? [])],
50
+ help: violation.help,
51
+ helpUrl: violation.helpUrl,
52
+ pageUrl: input.pageUrl,
53
+ pageKey,
54
+ target: Array.isArray(node.target) ? [...node.target] : [],
55
+ selectorHint: fingerprint.selectorHint ?? undefined,
56
+ targetHash: fingerprint.targetHash,
57
+ nodeHtml: truncate(node.html, htmlMaxLength),
58
+ failureSummary,
59
+ failureHint,
60
+ meta: input.meta,
61
+ })
62
+ }
63
+ }
64
+
65
+ return findings
66
+ }