Skip to content

Commit

Permalink
feat: Node API in ui5 linter (#400)
Browse files Browse the repository at this point in the history
JIRA: CPOUI5FOUNDATION-885

---------

Co-authored-by: Merlin Beutlberger <[email protected]>
  • Loading branch information
d3xter666 and RandomByte authored Nov 13, 2024
1 parent dd2e5a9 commit 626f022
Show file tree
Hide file tree
Showing 20 changed files with 2,783 additions and 79 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ jobs:

- name: Send report to Coveralls for package @ui5/linter
uses: coverallsapp/[email protected]

- name: Run e2e tests
run: npm run e2e
5 changes: 5 additions & 0 deletions ava-e2e.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import defaultAvaConfig from "./ava.config.js";

defaultAvaConfig.files = ["test/e2e/**/*.ts"];

export default defaultAvaConfig;
1 change: 0 additions & 1 deletion ava.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Calculate nodeArguments based on the Node version
const nodeArguments = [
"--import=tsx/esm",
"--no-warnings=ExperimentalWarning",
Expand Down
4 changes: 4 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export default tseslint.config(
"test/tmp/*",
"test/projects/*",
"test/fixtures/*",
// This file must be excluded as it tests the package exports by
// requiring the package itself, which causes a circular dependency
// and TypeScript/ESlint gets confused during compilation.
"test/e2e/package-exports.ts",

// Exclude generated code
"lib/*",
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,22 @@
"ui5lint": "bin/ui5lint.js"
},
"type": "module",
"types": "lib/index.d.ts",
"scripts": {
"build": "npm run cleanup && tsc -p tsconfig.build.json",
"build-test": "tsc --noEmit -p .",
"build-watch": "npm run cleanup && tsc -w -p tsconfig.build.json",
"check-licenses": "licensee --errors-only",
"cleanup": "rimraf lib coverage",
"coverage": "nyc ava --node-arguments=\"--experimental-loader=@istanbuljs/esm-loader-hook\"",
"depcheck": "depcheck --ignores @commitlint/config-conventional,@istanbuljs/esm-loader-hook,rimraf,sap,mycomp",
"depcheck": "depcheck --ignores @commitlint/config-conventional,@istanbuljs/esm-loader-hook,rimraf,sap,mycomp,@ui5/linter",
"hooks:pre-push": "npm run lint:commit",
"lint": "eslint .",
"lint:commit": "commitlint -e",
"prepare": "node ./.husky/skip.js || husky",
"test": "npm run lint && npm run build-test && npm run coverage && npm run depcheck",
"test": "npm run lint && npm run build-test && npm run coverage && npm run e2e && npm run depcheck",
"unit": "ava",
"e2e": "npm run build && ava --config ava-e2e.config.js",
"unit-debug": "ava debug",
"unit-update-snapshots": "ava --update-snapshots",
"unit-watch": "ava --watch",
Expand All @@ -57,6 +59,10 @@
"node": "^20.11.0 || >=22.0.0",
"npm": ">= 8"
},
"exports": {
".": "./lib/index.js",
"./package.json": "./package.json"
},
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.25",
Expand Down
12 changes: 6 additions & 6 deletions src/cli/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ async function handleLint(argv: ArgumentsCamelCase<LinterArg>) {
const {
files: filePatterns,
coverage,
ignorePattern,
ignorePattern: ignorePatterns,
details,
format,
config,
Expand All @@ -152,15 +152,15 @@ async function handleLint(argv: ArgumentsCamelCase<LinterArg>) {

const res = await lintProject({
rootDir: path.join(process.cwd()),
ignorePattern,
ignorePatterns,
filePatterns,
reportCoverage,
includeMessageDetails: details,
coverage: reportCoverage,
details,
configPath: config,
ui5ConfigPath: ui5Config,
ui5Config,
});

if (reportCoverage) {
if (coverage) {
const coverageFormatter = new Coverage();
await writeFile("ui5lint-report.html", await coverageFormatter.format(res));
}
Expand Down
70 changes: 70 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {lintProject} from "./linter/linter.js";
import type {LintResult} from "./linter/LinterContext.js";

export type {LintResult} from "./linter/LinterContext.js";

// Define a separate interface for the Node API as there could be some differences
// in the options and behavior compared to LinterOptions internal type.
export interface UI5LinterOptions {
/**
* List of patterns to lint.
*/
filePatterns?: string[];
/**
* Pattern/files that will be ignored during linting.
*/
ignorePatterns?: string[];
/**
* Provides complementary information for each finding, if available
* @default false
*/
details?: boolean;
/**
* Path to a ui5lint.config.(cjs|mjs|js) file
*/
config?: string;
/**
* Whether to skip loading of the ui5lint.config.(cjs|mjs|js) config file
* @default false
*/
noConfig?: boolean;
/**
* Whether to provide a coverage report
* @default false
*/
coverage?: boolean;
/**
* Path to a ui5.yaml file or an object representation of ui5.yaml
* @default "./ui5.yaml"
*/
ui5Config?: string | object;
/**
* Root directory of the project
* @default process.cwd()
*/
rootDir?: string;
}

export async function ui5lint(options?: UI5LinterOptions): Promise<LintResult[]> {
const {
filePatterns,
ignorePatterns = [],
details = false,
config,
noConfig,
coverage = false,
ui5Config = "./ui5.yaml",
rootDir = process.cwd(),
} = options ?? {};

return lintProject({
rootDir,
filePatterns,
ignorePatterns,
coverage,
details,
configPath: config,
noConfig,
ui5Config,
});
}
13 changes: 7 additions & 6 deletions src/linter/LinterContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,12 @@ export interface TranspileResult {
export interface LinterOptions {
rootDir: string;
filePatterns?: FilePattern[];
ignorePattern?: FilePattern[];
reportCoverage?: boolean;
includeMessageDetails?: boolean;
ignorePatterns?: FilePattern[];
coverage?: boolean;
details?: boolean;
configPath?: string;
ui5ConfigPath?: string;
noConfig?: boolean;
ui5Config?: string | object;
namespace?: string;
}

Expand Down Expand Up @@ -111,8 +112,8 @@ export default class LinterContext {
constructor(options: LinterOptions) {
this.#rootDir = options.rootDir;
this.#namespace = options.namespace;
this.#reportCoverage = !!options.reportCoverage;
this.#includeMessageDetails = !!options.includeMessageDetails;
this.#reportCoverage = !!options.coverage;
this.#includeMessageDetails = !!options.details;
}

getRootDir(): string {
Expand Down
2 changes: 1 addition & 1 deletion src/linter/lintWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default async function lintWorkspace(
patternsMatch,
});
reader = resolveReader({
patterns: options.ignorePattern ?? [],
patterns: options.ignorePatterns ?? [],
resourceReader: reader,
patternsMatch,
relFsBasePath: relFsBasePath ?? "",
Expand Down
60 changes: 37 additions & 23 deletions src/linter/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ import ConfigManager, {UI5LintConfigType} from "../utils/ConfigManager.js";
import {Minimatch} from "minimatch";

export async function lintProject({
rootDir, filePatterns, ignorePattern, reportCoverage, includeMessageDetails, configPath, ui5ConfigPath,
rootDir, filePatterns, ignorePatterns, coverage, details, configPath, ui5Config, noConfig,
}: LinterOptions): Promise<LintResult[]> {
const configMngr = new ConfigManager(rootDir, configPath);
const config = await configMngr.getConfiguration();
let config: UI5LintConfigType = {};
if (noConfig !== true) {
const configMngr = new ConfigManager(rootDir, configPath);
config = await configMngr.getConfiguration();
}

// In case path is set both by CLI and config use CLI
ui5ConfigPath = ui5ConfigPath ?? config.ui5Config;
ui5Config = ui5Config ?? config.ui5Config;

const projectGraphDone = taskStart("Project Graph creation");
const graph = await getProjectGraph(rootDir, ui5ConfigPath);
const graph = await getProjectGraph(rootDir, ui5Config);
const project = graph.getRoot();
projectGraphDone();

Expand Down Expand Up @@ -61,10 +64,12 @@ export async function lintProject({
rootDir,
namespace: project.getNamespace(),
filePatterns,
ignorePattern,
reportCoverage,
includeMessageDetails,
ignorePatterns,
coverage,
details,
configPath,
noConfig,
ui5Config,
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
}, config);

Expand All @@ -80,10 +85,13 @@ export async function lintProject({
}

export async function lintFile({
rootDir, filePatterns, ignorePattern, namespace, reportCoverage, includeMessageDetails, configPath,
rootDir, filePatterns, ignorePatterns, namespace, coverage, details, configPath, noConfig,
}: LinterOptions): Promise<LintResult[]> {
const configMngr = new ConfigManager(rootDir, configPath);
const config = await configMngr.getConfiguration();
let config: UI5LintConfigType = {};
if (noConfig !== true) {
const configMngr = new ConfigManager(rootDir, configPath);
config = await configMngr.getConfiguration();
}

const virBasePath = namespace ? `/resources/${namespace}/` : "/";
const reader = createReader({
Expand All @@ -95,9 +103,9 @@ export async function lintFile({
rootDir,
namespace,
filePatterns,
ignorePattern,
reportCoverage,
includeMessageDetails,
ignorePatterns,
coverage,
details,
configPath,
relFsBasePath: "",
virBasePath,
Expand All @@ -117,7 +125,7 @@ async function lint(
config: UI5LintConfigType
): Promise<LintResult[]> {
const lintEnd = taskStart("Linting");
let {ignorePattern, filePatterns} = options;
let {ignorePatterns, filePatterns} = options;
const {relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest} = options;

// Resolve files to include
Expand All @@ -129,15 +137,15 @@ async function lint(
const matchedPatterns = new Set<string>();

// Resolve ignores
ignorePattern = [
ignorePatterns = [
...(config.ignores ?? []),
...(ignorePattern ?? []), // CLI patterns go after config patterns
...(ignorePatterns ?? []), // CLI patterns go after config patterns
].filter(($) => $);
// Apply ignores to the workspace reader.
// TypeScript needs the full context to provide correct analysis.
// so, we can do filtering later via the filePathsReader
const reader = resolveReader({
patterns: ignorePattern,
patterns: ignorePatterns,
resourceReader,
patternsMatch: matchedPatterns,
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
Expand All @@ -152,7 +160,7 @@ async function lint(
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
});
filePathsReader = resolveReader({
patterns: ignorePattern,
patterns: ignorePatterns,
resourceReader: filePathsReader,
patternsMatch: matchedPatterns,
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
Expand All @@ -170,13 +178,19 @@ async function lint(
return res;
}

async function getProjectGraph(rootDir: string, ui5ConfigPath?: string): Promise<ProjectGraph> {
async function getProjectGraph(rootDir: string, ui5Config?: string | object): Promise<ProjectGraph> {
let rootConfigPath, rootConfiguration;
const ui5YamlPath = ui5ConfigPath ? path.join(rootDir, ui5ConfigPath) : path.join(rootDir, "ui5.yaml");
if (await fileExists(ui5YamlPath)) {
let ui5YamlPath;
if (typeof ui5Config !== "object") {
ui5YamlPath = ui5Config ? path.join(rootDir, ui5Config) : path.join(rootDir, "ui5.yaml");
}

if (typeof ui5Config === "object") {
rootConfiguration = ui5Config;
} else if (ui5YamlPath && await fileExists(ui5YamlPath)) {
rootConfigPath = ui5YamlPath;
} else {
if (ui5ConfigPath) throw new Error(`Unable to find UI5 config file '${ui5ConfigPath}'`);
if (ui5Config) throw new Error(`Unable to find UI5 config file '${ui5Config}'`);
const isApp = await dirExists(path.join(rootDir, "webapp"));
if (isApp) {
rootConfiguration = {
Expand Down
22 changes: 22 additions & 0 deletions test/e2e/package-exports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import test from "ava";
import {createRequire} from "node:module";

// Using CommonsJS require since JSON module imports are still experimental
const require = createRequire(import.meta.url);

test.serial("Package exports: export of package.json", (t) => {
t.truthy(require("@ui5/linter/package.json").version);
});

// Check number of defined exports
test.serial("Package exports: check number of exports", (t) => {
const packageJson = require("@ui5/linter/package.json");
t.is(Object.keys(packageJson.exports).length, 2);
});

// Public API contract (exported modules)
test.serial("Package exports: @ui5/linter", async (t) => {
const actual = await import("@ui5/linter");
const expected = await import("../../lib/index.js");
t.is(actual, expected, "Correct module exported");
});
Loading

0 comments on commit 626f022

Please sign in to comment.