@ksmv/ui-checks 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.
@@ -0,0 +1,11 @@
1
+ import type { ThemeConfig } from "../config.js";
2
+ import { type ContrastManifest } from "../contracts.js";
3
+ import type { CheckRule } from "../scan.js";
4
+ export type { ContrastManifest, ContrastPair } from "../contracts.js";
5
+ export interface TokenContrastOptions {
6
+ computed?: Record<string, Record<string, string>>;
7
+ manifest: ContrastManifest;
8
+ themes: ThemeConfig[];
9
+ }
10
+ export declare function loadContrastManifest(url?: URL): Promise<ContrastManifest>;
11
+ export declare function createTokenContrastRule({ computed, manifest, themes, }: TokenContrastOptions): CheckRule;
@@ -0,0 +1,170 @@
1
+ import { isComputedPropertyName, isIdentifier, isJsxAttribute, isJsxExpression, isObjectLiteralExpression, isPropertyAssignment, isJsxSpreadAttribute, isStringLiteralLikeNode, } from "typescript/unstable/ast";
2
+ import { contrastRatio, isColorBearingProperty, resolveColor, } from "../color.js";
3
+ import { parseContrastManifest, } from "../contracts.js";
4
+ import { createThemeCollector } from "./theme.js";
5
+ export async function loadContrastManifest(url) {
6
+ const { readFile } = await import("node:fs/promises");
7
+ const packaged = url ?? new URL("../contrast-contract.json", import.meta.url);
8
+ return parseContrastManifest(await readFile(packaged, "utf8"));
9
+ }
10
+ function findingLocation(theme) {
11
+ const first = theme.declarations.values().next().value;
12
+ return {
13
+ column: first?.column ?? 1,
14
+ line: first?.line ?? 1,
15
+ path: first?.path ?? [...theme.files].sort()[0] ?? "theme.css",
16
+ };
17
+ }
18
+ function propertyName(node) {
19
+ if (isIdentifier(node) || isStringLiteralLikeNode(node)) {
20
+ return node.text;
21
+ }
22
+ return isComputedPropertyName(node) && isStringLiteralLikeNode(node.expression)
23
+ ? node.expression.text
24
+ : undefined;
25
+ }
26
+ function dynamicStyleResult(file) {
27
+ const result = { uncovered: [], violations: [] };
28
+ if (!file.sourceFile) {
29
+ return result;
30
+ }
31
+ const sourceFile = file.sourceFile;
32
+ const visit = (node) => {
33
+ if (isJsxSpreadAttribute(node)) {
34
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
35
+ result.uncovered.push({
36
+ column: position.character + 1,
37
+ line: position.line + 1,
38
+ message: "JSX prop spreads require computed coverage.",
39
+ nodeKind: "tsx-props-spread",
40
+ path: file.path,
41
+ rule: "tokens/contrast",
42
+ });
43
+ }
44
+ if (isJsxAttribute(node) &&
45
+ isIdentifier(node.name) &&
46
+ (node.name.text === "className" ||
47
+ node.name.text === "style" ||
48
+ isColorBearingProperty(node.name.text)) &&
49
+ node.initializer &&
50
+ isJsxExpression(node.initializer) &&
51
+ node.initializer.expression) {
52
+ let isDynamic = !isStringLiteralLikeNode(node.initializer.expression);
53
+ if (node.name.text === "style" &&
54
+ isObjectLiteralExpression(node.initializer.expression)) {
55
+ isDynamic = node.initializer.expression.properties.some((property) => {
56
+ if (!("name" in property)) {
57
+ return true;
58
+ }
59
+ const name = propertyName(property.name);
60
+ if (name === undefined) {
61
+ return true;
62
+ }
63
+ if (!isColorBearingProperty(name)) {
64
+ return false;
65
+ }
66
+ return (!isPropertyAssignment(property) ||
67
+ !isStringLiteralLikeNode(property.initializer));
68
+ });
69
+ }
70
+ if (isDynamic) {
71
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
72
+ result.uncovered.push({
73
+ column: position.character + 1,
74
+ line: position.line + 1,
75
+ message: "Dynamic class or color expressions require computed coverage.",
76
+ nodeKind: "tsx-attribute",
77
+ path: file.path,
78
+ rule: "tokens/contrast",
79
+ });
80
+ }
81
+ }
82
+ node.forEachChild(visit);
83
+ };
84
+ sourceFile.forEachChild(visit);
85
+ return result;
86
+ }
87
+ export function createTokenContrastRule({ computed = {}, manifest, themes, }) {
88
+ const collector = createThemeCollector(themes);
89
+ return {
90
+ name: "tokens/contrast",
91
+ begin: collector.begin,
92
+ check(file) {
93
+ collector.collect(file);
94
+ return dynamicStyleResult(file);
95
+ },
96
+ complete() {
97
+ const result = { uncovered: [], violations: [] };
98
+ for (const theme of collector.themes) {
99
+ const location = findingLocation(theme);
100
+ const computedTheme = computed[theme.name] ?? {};
101
+ const hasCompleteComputedEvidence = manifest.pairs.every(({ background, foreground }) => computedTheme[background] !== undefined &&
102
+ computedTheme[foreground] !== undefined);
103
+ if (theme.overlapping && !hasCompleteComputedEvidence) {
104
+ result.uncovered.push({
105
+ ...location,
106
+ message: `Theme ${theme.name} uses overlapping selectors.`,
107
+ nodeKind: "theme-selector",
108
+ rule: "tokens/contrast",
109
+ required: true,
110
+ });
111
+ continue;
112
+ }
113
+ const missingComputedEndpoints = theme.unresolved
114
+ ? manifest.pairs.flatMap(({ background, foreground }) => [background, foreground].filter((token) => computedTheme[token] === undefined))
115
+ : [];
116
+ if (missingComputedEndpoints.length > 0) {
117
+ result.uncovered.push({
118
+ ...location,
119
+ message: `Theme ${theme.name} has unresolved cascade conditions.`,
120
+ nodeKind: "theme-cascade",
121
+ rule: "tokens/contrast",
122
+ required: true,
123
+ });
124
+ continue;
125
+ }
126
+ const tokens = new Map([...theme.declarations].map(([name, declaration]) => [
127
+ name,
128
+ declaration.value,
129
+ ]));
130
+ for (const [name, value] of Object.entries(computedTheme)) {
131
+ tokens.set(name, value);
132
+ }
133
+ for (const pair of manifest.pairs) {
134
+ const background = resolveColor(tokens.get(pair.background), tokens);
135
+ const foreground = resolveColor(tokens.get(pair.foreground), tokens);
136
+ if (background.status === "uncovered" ||
137
+ foreground.status === "uncovered") {
138
+ result.uncovered.push({
139
+ ...location,
140
+ message: `Contrast pair ${pair.context} could not be resolved.`,
141
+ nodeKind: `contrast-pair:${pair.context}:${pair.foreground}:${pair.background}`,
142
+ rule: "tokens/contrast",
143
+ required: true,
144
+ });
145
+ continue;
146
+ }
147
+ const ratio = contrastRatio(foreground.color, background.color);
148
+ if (ratio === undefined) {
149
+ result.uncovered.push({
150
+ ...location,
151
+ message: `Contrast pair ${pair.context} requires a known opaque background.`,
152
+ nodeKind: `contrast-pair:${pair.context}:${pair.foreground}:${pair.background}`,
153
+ rule: "tokens/contrast",
154
+ required: true,
155
+ });
156
+ }
157
+ else if (ratio < pair.minimum) {
158
+ result.violations.push({
159
+ ...location,
160
+ message: `Contrast pair ${pair.context} is below ${pair.minimum}:1.`,
161
+ nodeKind: `contrast-pair:${pair.context}:${pair.foreground}:${pair.background}`,
162
+ rule: "tokens/contrast",
163
+ });
164
+ }
165
+ }
166
+ }
167
+ return result;
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,2 @@
1
+ import type { CheckRule } from "../scan.js";
2
+ export declare function createTokenPrefixRule(): CheckRule;
@@ -0,0 +1,62 @@
1
+ import { isComputedPropertyName, isIdentifier, isJsxAttribute, isJsxExpression, isObjectLiteralExpression, isPropertyAssignment, isStringLiteralLikeNode, } from "typescript/unstable/ast";
2
+ export function createTokenPrefixRule() {
3
+ return {
4
+ name: "tokens/prefix",
5
+ check(file) {
6
+ const violations = [];
7
+ file.cssRoot?.walkDecls((declaration) => {
8
+ if (declaration.prop.startsWith("--") &&
9
+ !declaration.prop.startsWith("--ui-")) {
10
+ violations.push({
11
+ column: declaration.source?.start?.column ?? 1,
12
+ line: declaration.source?.start?.line ?? 1,
13
+ message: "Public custom properties must use the --ui- prefix.",
14
+ nodeKind: "css-custom-property",
15
+ path: file.path,
16
+ rule: "tokens/prefix",
17
+ });
18
+ }
19
+ });
20
+ if (file.sourceFile) {
21
+ const sourceFile = file.sourceFile;
22
+ const visit = (node) => {
23
+ if (isJsxAttribute(node) &&
24
+ isIdentifier(node.name) &&
25
+ node.name.text === "style" &&
26
+ node.initializer &&
27
+ isJsxExpression(node.initializer) &&
28
+ node.initializer.expression &&
29
+ isObjectLiteralExpression(node.initializer.expression)) {
30
+ for (const property of node.initializer.expression.properties) {
31
+ if (!isPropertyAssignment(property)) {
32
+ continue;
33
+ }
34
+ const propertyNode = property.name;
35
+ const name = isIdentifier(propertyNode) ||
36
+ isStringLiteralLikeNode(propertyNode)
37
+ ? propertyNode.text
38
+ : isComputedPropertyName(propertyNode) &&
39
+ isStringLiteralLikeNode(propertyNode.expression)
40
+ ? propertyNode.expression.text
41
+ : undefined;
42
+ if (name?.startsWith("--") && !name.startsWith("--ui-")) {
43
+ const position = sourceFile.getLineAndCharacterOfPosition(propertyNode.getStart(sourceFile));
44
+ violations.push({
45
+ column: position.character + 1,
46
+ line: position.line + 1,
47
+ message: "Public custom properties must use the --ui- prefix.",
48
+ nodeKind: "tsx-custom-property",
49
+ path: file.path,
50
+ rule: "tokens/prefix",
51
+ });
52
+ }
53
+ }
54
+ }
55
+ node.forEachChild(visit);
56
+ };
57
+ sourceFile.forEachChild(visit);
58
+ }
59
+ return { uncovered: [], violations };
60
+ },
61
+ };
62
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type Root } from "postcss";
2
+ import { SyntaxKind, type SourceFile } from "typescript/unstable/ast";
3
+ import type { ChecksConfig } from "./config.js";
4
+ import type { CheckReport, Uncovered, Violation } from "./result.js";
5
+ export interface ScannedFile {
6
+ content: string;
7
+ cssRoot?: Root;
8
+ path: string;
9
+ sourceFile?: SourceFile;
10
+ typescriptTokens?: TypeScriptToken[];
11
+ }
12
+ export interface TypeScriptToken {
13
+ end: number;
14
+ kind: SyntaxKind;
15
+ start: number;
16
+ text: string;
17
+ }
18
+ export interface RuleResult {
19
+ uncovered: Uncovered[];
20
+ violations: Violation[];
21
+ }
22
+ export interface CheckRule {
23
+ begin?(): Promise<void> | void;
24
+ check(file: ScannedFile): Promise<RuleResult> | RuleResult;
25
+ complete?(): Promise<RuleResult> | RuleResult;
26
+ name: string;
27
+ }
28
+ export interface RunChecksOptions {
29
+ applyGovernance?: boolean;
30
+ /**
31
+ * Replaces disk reads for rule input. The TypeScript API cannot be handed an
32
+ * in-memory source, so supplying this disables type analysis and records an
33
+ * operational error: a run must never mix rule findings from one version of a
34
+ * file with type findings from another. Intended for tests that need to
35
+ * simulate an I/O failure.
36
+ */
37
+ readFile?: (absolutePath: string) => Promise<string>;
38
+ rules?: CheckRule[];
39
+ }
40
+ export declare function runChecks(config: ChecksConfig, options?: RunChecksOptions): Promise<CheckReport>;
package/dist/scan.js ADDED
@@ -0,0 +1,398 @@
1
+ import { glob, readFile as readFileFromDisk } from "node:fs/promises";
2
+ import { extname, relative, resolve } from "node:path";
3
+ import postcss from "postcss";
4
+ import { computeLineStarts, createScanner, LanguageVariant, SyntaxKind, } from "typescript/unstable/ast";
5
+ import { API } from "typescript/unstable/sync";
6
+ import { resolveContractSet } from "./contracts.js";
7
+ import { loadComputedEvidence } from "./evidence.js";
8
+ import { applyDebtControls } from "./governance.js";
9
+ const supportedExtensions = [".css", ".js", ".jsx", ".ts", ".tsx"];
10
+ function toPosixPath(path) {
11
+ return path.replaceAll("\\", "/");
12
+ }
13
+ function compareFindings(left, right) {
14
+ return (left.path.localeCompare(right.path) ||
15
+ left.line - right.line ||
16
+ left.column - right.column ||
17
+ left.rule.localeCompare(right.rule) ||
18
+ left.message.localeCompare(right.message));
19
+ }
20
+ function scanTypeScript(content, path) {
21
+ const scanner = createScanner(true, LanguageVariant.JSX, content);
22
+ const tokens = [];
23
+ const uncovered = [];
24
+ let kind = scanner.scan();
25
+ while (kind !== SyntaxKind.EndOfFile) {
26
+ tokens.push({
27
+ end: scanner.getTokenEnd(),
28
+ kind,
29
+ start: scanner.getTokenStart(),
30
+ text: scanner.getTokenText(),
31
+ });
32
+ kind = scanner.scan();
33
+ }
34
+ const lineStarts = computeLineStarts(content);
35
+ for (let index = 0; index < tokens.length; index += 1) {
36
+ const attribute = tokens[index];
37
+ if (attribute.kind !== SyntaxKind.Identifier ||
38
+ (attribute.text !== "className" && attribute.text !== "style") ||
39
+ tokens[index + 1]?.kind !== SyntaxKind.EqualsToken ||
40
+ tokens[index + 2]?.kind !== SyntaxKind.OpenBraceToken) {
41
+ continue;
42
+ }
43
+ const expression = tokens[index + 3];
44
+ const isStatic = expression?.kind === SyntaxKind.StringLiteral ||
45
+ expression?.kind === SyntaxKind.NoSubstitutionTemplateLiteral ||
46
+ expression?.kind === SyntaxKind.NumericLiteral ||
47
+ (attribute.text === "style" &&
48
+ expression?.kind === SyntaxKind.OpenBraceToken);
49
+ if (isStatic) {
50
+ continue;
51
+ }
52
+ let lineIndex = 0;
53
+ while (lineIndex + 1 < lineStarts.length &&
54
+ lineStarts[lineIndex + 1] <= attribute.start) {
55
+ lineIndex += 1;
56
+ }
57
+ uncovered.push({
58
+ column: attribute.start - lineStarts[lineIndex] + 1,
59
+ line: lineIndex + 1,
60
+ message: "Dynamic class or style expression requires computed coverage.",
61
+ nodeKind: "tsx-attribute",
62
+ path,
63
+ rule: "coverage/dynamic-expression",
64
+ });
65
+ }
66
+ const nodesChecked = content.match(/<[A-Za-z][\w.-]*/g)?.length ?? 0;
67
+ return { nodesChecked, tokens, uncovered };
68
+ }
69
+ async function discoverFiles(root, include, exclude = []) {
70
+ const paths = new Set();
71
+ for await (const path of glob(include, { cwd: root, exclude })) {
72
+ if (supportedExtensions.includes(extname(path).toLowerCase())) {
73
+ paths.add(resolve(root, path));
74
+ }
75
+ }
76
+ return [...paths].sort((left, right) => left.localeCompare(right));
77
+ }
78
+ function emptyReport(rulesExecuted) {
79
+ return {
80
+ baselineDebt: [],
81
+ baselineMatches: [],
82
+ declarationsChecked: 0,
83
+ durationMs: 0,
84
+ exitCode: 0,
85
+ filesDiscovered: 0,
86
+ filesIgnored: [],
87
+ filesRead: 0,
88
+ nodesChecked: 0,
89
+ operationalErrors: [],
90
+ rulesExecuted,
91
+ schemaVersion: 1,
92
+ suppressions: [],
93
+ uncovered: [],
94
+ violations: [],
95
+ };
96
+ }
97
+ function resolveExitCode(report, failOnUncovered) {
98
+ if (report.operationalErrors.length > 0 ||
99
+ report.uncovered.some(({ required }) => required === true) ||
100
+ (failOnUncovered && report.uncovered.length > 0)) {
101
+ return 2;
102
+ }
103
+ return report.violations.length > 0 ? 1 : 0;
104
+ }
105
+ function validateRuntimeConfig(value) {
106
+ if (!value || typeof value !== "object") {
107
+ return "Configuration must be an object.";
108
+ }
109
+ const candidate = value;
110
+ if (!Array.isArray(candidate.include) ||
111
+ candidate.include.length === 0 ||
112
+ candidate.include.some((pattern) => typeof pattern !== "string" || pattern.length === 0)) {
113
+ return "At least one include pattern is required.";
114
+ }
115
+ if (candidate.exclude !== undefined &&
116
+ (!Array.isArray(candidate.exclude) ||
117
+ candidate.exclude.some((pattern) => typeof pattern !== "string"))) {
118
+ return "Exclude patterns must be an array of strings.";
119
+ }
120
+ if (candidate.failOnUncovered !== undefined &&
121
+ typeof candidate.failOnUncovered !== "boolean") {
122
+ return "failOnUncovered must be a boolean.";
123
+ }
124
+ if (candidate.root !== undefined && typeof candidate.root !== "string") {
125
+ return "Root must be a string.";
126
+ }
127
+ if (candidate.contractSource !== undefined &&
128
+ candidate.contractSource !== "bundled" &&
129
+ candidate.contractSource !== "@ksmv/ui-react") {
130
+ return "contractSource must be bundled or @ksmv/ui-react.";
131
+ }
132
+ if (candidate.themes !== undefined &&
133
+ (!Array.isArray(candidate.themes) ||
134
+ candidate.themes.some((theme) => !theme ||
135
+ typeof theme !== "object" ||
136
+ typeof theme.name !== "string" ||
137
+ theme.name.trim().length === 0 ||
138
+ typeof theme.selector !== "string" ||
139
+ theme.selector.trim().length === 0 ||
140
+ !Array.isArray(theme.files) ||
141
+ theme.files.length === 0 ||
142
+ theme.files.some((path) => typeof path !== "string" || path.length === 0) ||
143
+ (theme.computedEvidence !== undefined &&
144
+ (!theme.computedEvidence ||
145
+ typeof theme.computedEvidence !== "object" ||
146
+ typeof theme.computedEvidence.file !== "string" ||
147
+ theme.computedEvidence.file.length === 0 ||
148
+ typeof theme.computedEvidence.theme !== "string" ||
149
+ theme.computedEvidence.theme.length === 0))))) {
150
+ return "Themes must be an array of named selectors with files.";
151
+ }
152
+ return undefined;
153
+ }
154
+ export async function runChecks(config, options = {}) {
155
+ const startedAt = performance.now();
156
+ const configurationError = validateRuntimeConfig(config);
157
+ if (configurationError) {
158
+ const report = emptyReport(["coverage/dynamic-expression"]);
159
+ report.operationalErrors.push({
160
+ code: "config",
161
+ message: configurationError,
162
+ });
163
+ report.exitCode = 2;
164
+ report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
165
+ return report;
166
+ }
167
+ const usesDefaultRules = options.rules === undefined;
168
+ const root = resolve(config.root ?? process.cwd());
169
+ if (usesDefaultRules && config.contractSource === undefined) {
170
+ const report = emptyReport(["coverage/dynamic-expression"]);
171
+ report.operationalErrors.push({
172
+ code: "config",
173
+ message: "contractSource is required when using the default rules.",
174
+ });
175
+ report.exitCode = 2;
176
+ report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
177
+ return report;
178
+ }
179
+ let rules;
180
+ let contracts;
181
+ try {
182
+ let configuredRules = options.rules;
183
+ if (configuredRules === undefined) {
184
+ contracts = await resolveContractSet({
185
+ root,
186
+ source: config.contractSource,
187
+ });
188
+ const computed = await loadComputedEvidence({
189
+ contracts,
190
+ root,
191
+ themes: config.themes ?? [],
192
+ });
193
+ configuredRules = await (await import("./rules/default.js")).createDefaultRules({
194
+ computed,
195
+ contract: contracts.theme,
196
+ manifest: contracts.contrast,
197
+ themes: config.themes ?? [],
198
+ });
199
+ }
200
+ rules = [...(await configuredRules)].sort((left, right) => left.name.localeCompare(right.name));
201
+ }
202
+ catch {
203
+ const report = emptyReport(["coverage/dynamic-expression"]);
204
+ if (contracts) {
205
+ report.contracts = {
206
+ contrast: contracts.identities.contrast,
207
+ source: contracts.source,
208
+ theme: contracts.identities.theme,
209
+ };
210
+ }
211
+ report.operationalErrors.push({
212
+ code: usesDefaultRules ? "config" : "internal",
213
+ message: "Unable to initialize configured rules.",
214
+ });
215
+ report.exitCode = 2;
216
+ report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
217
+ return report;
218
+ }
219
+ const report = emptyReport([
220
+ "coverage/dynamic-expression",
221
+ ...rules.map(({ name }) => name),
222
+ ]);
223
+ if (contracts) {
224
+ report.contracts = {
225
+ contrast: contracts.identities.contrast,
226
+ source: contracts.source,
227
+ theme: contracts.identities.theme,
228
+ };
229
+ }
230
+ for (const rule of rules) {
231
+ await rule.begin?.();
232
+ }
233
+ if (!Array.isArray(config.include) || config.include.length === 0) {
234
+ report.operationalErrors.push({
235
+ code: "config",
236
+ message: "At least one include pattern is required.",
237
+ });
238
+ report.exitCode = 2;
239
+ report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
240
+ return report;
241
+ }
242
+ const includedBeforeExcludes = await discoverFiles(root, config.include);
243
+ const absolutePaths = await discoverFiles(root, config.include, config.exclude);
244
+ const selected = new Set(absolutePaths);
245
+ report.filesIgnored = includedBeforeExcludes
246
+ .filter((path) => !selected.has(path))
247
+ .map((path) => ({
248
+ path: toPosixPath(relative(root, path)),
249
+ reason: "excluded-by-configuration",
250
+ }))
251
+ .sort((left, right) => left.path.localeCompare(right.path));
252
+ report.filesDiscovered = includedBeforeExcludes.length;
253
+ if (absolutePaths.length === 0) {
254
+ report.operationalErrors.push({
255
+ code: "no-files",
256
+ message: "No files matched the configured include patterns.",
257
+ });
258
+ report.exitCode = 2;
259
+ report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
260
+ return report;
261
+ }
262
+ const readSource = options.readFile ??
263
+ ((absolutePath) => readFileFromDisk(absolutePath, "utf8"));
264
+ // The TypeScript API always reads from disk. If rule input comes from
265
+ // somewhere else, the two would describe different versions of the same file
266
+ // in one report, so type analysis is refused rather than silently mixed.
267
+ const typeScriptPaths = options.readFile
268
+ ? []
269
+ : absolutePaths.filter((path) => extname(path).toLowerCase() !== ".css");
270
+ if (options.readFile) {
271
+ report.operationalErrors.push({
272
+ code: "internal",
273
+ message: "Type analysis is unavailable when file reading is overridden.",
274
+ });
275
+ }
276
+ let typeScriptApi;
277
+ let typeScriptSnapshot;
278
+ if (typeScriptPaths.length > 0) {
279
+ try {
280
+ typeScriptApi = new API({ cwd: root });
281
+ typeScriptSnapshot = typeScriptApi.updateSnapshot({
282
+ openFiles: typeScriptPaths,
283
+ });
284
+ }
285
+ catch {
286
+ typeScriptApi?.close();
287
+ typeScriptApi = undefined;
288
+ report.operationalErrors.push({
289
+ code: "internal",
290
+ message: "Unable to initialize TypeScript analysis.",
291
+ });
292
+ }
293
+ }
294
+ for (const absolutePath of absolutePaths) {
295
+ const path = toPosixPath(relative(root, absolutePath));
296
+ let content;
297
+ try {
298
+ content = await readSource(absolutePath);
299
+ report.filesRead += 1;
300
+ }
301
+ catch {
302
+ report.operationalErrors.push({
303
+ code: "read",
304
+ message: "Unable to read file.",
305
+ path,
306
+ });
307
+ continue;
308
+ }
309
+ if (usesDefaultRules &&
310
+ config.contractSource === "bundled" &&
311
+ /(?:from\s*|import\s*(?:\(\s*)?)["']@ksmv\/ui-react(?:\/[^"']*)?["']/.test(content) &&
312
+ !report.operationalErrors.some(({ code, message }) => code === "config" &&
313
+ message === "React source requires React package contracts.")) {
314
+ report.operationalErrors.push({
315
+ code: "config",
316
+ message: "React source requires React package contracts.",
317
+ path,
318
+ });
319
+ }
320
+ const file = { content, path };
321
+ const extension = extname(absolutePath).toLowerCase();
322
+ try {
323
+ if (extension === ".css") {
324
+ file.cssRoot = postcss.parse(content, { from: absolutePath });
325
+ file.cssRoot.walkDecls(() => {
326
+ report.declarationsChecked += 1;
327
+ });
328
+ }
329
+ else {
330
+ const coverage = scanTypeScript(content, path);
331
+ file.typescriptTokens = coverage.tokens;
332
+ const project = typeScriptSnapshot?.getDefaultProjectForFile(absolutePath);
333
+ if (typeScriptSnapshot && !project) {
334
+ throw new Error("parse");
335
+ }
336
+ if (project) {
337
+ if (project.program.getSyntacticDiagnostics(absolutePath).length > 0) {
338
+ throw new Error("parse");
339
+ }
340
+ file.sourceFile = project.program.getSourceFile(absolutePath);
341
+ }
342
+ report.nodesChecked += coverage.nodesChecked;
343
+ report.uncovered.push(...coverage.uncovered);
344
+ }
345
+ }
346
+ catch {
347
+ report.operationalErrors.push({
348
+ code: "parse",
349
+ message: "Unable to parse file.",
350
+ path,
351
+ });
352
+ continue;
353
+ }
354
+ for (const rule of rules) {
355
+ try {
356
+ const result = await rule.check(file);
357
+ report.violations.push(...result.violations);
358
+ report.uncovered.push(...result.uncovered);
359
+ }
360
+ catch {
361
+ report.operationalErrors.push({
362
+ code: "internal",
363
+ message: `Rule ${rule.name} could not complete.`,
364
+ path,
365
+ });
366
+ }
367
+ }
368
+ }
369
+ for (const rule of rules) {
370
+ if (!rule.complete) {
371
+ continue;
372
+ }
373
+ try {
374
+ const result = await rule.complete();
375
+ report.violations.push(...result.violations);
376
+ report.uncovered.push(...result.uncovered);
377
+ }
378
+ catch {
379
+ report.operationalErrors.push({
380
+ code: "internal",
381
+ message: `Rule ${rule.name} could not complete.`,
382
+ });
383
+ }
384
+ }
385
+ typeScriptSnapshot?.dispose();
386
+ typeScriptApi?.close();
387
+ if (options.applyGovernance ?? usesDefaultRules) {
388
+ await applyDebtControls(report, config);
389
+ }
390
+ report.violations.sort(compareFindings);
391
+ report.baselineMatches.sort(compareFindings);
392
+ report.uncovered.sort(compareFindings);
393
+ report.operationalErrors.sort((left, right) => (left.path ?? "").localeCompare(right.path ?? "") ||
394
+ left.code.localeCompare(right.code));
395
+ report.exitCode = resolveExitCode(report, config.failOnUncovered ?? true);
396
+ report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
397
+ return report;
398
+ }