scripts/gradleUsageShared.ts
Rama main · réplica de lectura del repositorio original.
/**
* Shared between generateGradleDependencyUsage.ts and
* checkUnusedGradleDependencies.ts.
*
* NewDot declares its own Gradle dependencies in android/app/build.gradle,
* separately from the ones Mobile-Expensify declares for HybridApp, so both
* repositories need this check.
*
* Two limitations, both deliberate:
*
* - `buildscript { classpath ... }` coordinates are out of scope. They are Gradle
* plugins, reached through `apply plugin:` and the `plugins` block rather than
* through an import, so neither route this check knows about can verify them.
* - The manifest is keyed by `group:artifact` with no configuration or source-set
* dimension, and the sources are read as one corpus. The same coordinate
* declared on two configurations collapses to one entry, and a coordinate is
* proved by an import from any source set.
*/
import fs from 'fs';
import path from 'path';
import {flavorForExtension, stripComments} from './nativeSourceComments';
type PackagesEntry = {usage: 'packages'; packages: string[]};
type ResourcesEntry = {usage: 'resources'; resources: string[]};
type ExemptEntry = {usage: 'exempt'; reason: string; reviewed: string};
type GradleUsageEntry = PackagesEntry | ResourcesEntry | ExemptEntry;
type GradleUsageManifest = Record<string, GradleUsageEntry>;
type SearchContext = {sources: string; resources: string};
const ANDROID_APP_DIR = path.resolve(__dirname, '..', 'android', 'app');
const MANIFEST_PATH = path.join(ANDROID_APP_DIR, 'dependency-usage.json');
const BUILD_GRADLE_PATH = path.join(ANDROID_APP_DIR, 'build.gradle');
const RELATIVE_BUILD_GRADLE = 'android/app/build.gradle';
const SOURCE_EXTENSIONS = new Set(['.java', '.kt']);
const SKIPPED_DIRECTORIES = new Set(['build', '.gradle', '.git', '.cxx', 'node_modules']);
/**
* Gradle names a configuration either on its own (`implementation`), prefixed by
* a source set or variant (`testImplementation`, `debugCompileOnly`), or suffixed
* for the annotation processors (`kaptTest`). All three forms declare a
* dependency that can go stale, so all three have to be read.
*/
const BASE_CONFIGURATIONS = [
'implementation',
'api',
'compileOnlyApi',
'compileOnly',
'runtimeOnly',
'annotationProcessor',
'kapt',
'ksp',
'detektPlugins',
'coreLibraryDesugaring',
'lintChecks',
'lintPublish',
];
const SUFFIX_CONFIGURATIONS = [
'Implementation',
'Api',
'CompileOnlyApi',
'CompileOnly',
'RuntimeOnly',
'AnnotationProcessor',
'CoreLibraryDesugaring',
'LintChecks',
'LintPublish',
'Kapt',
'Ksp',
'DetektPlugins',
'Util',
];
const CONFIGURATION = `(?:${BASE_CONFIGURATIONS.join('|')}|[a-zA-Z0-9]+(?:${SUFFIX_CONFIGURATIONS.join('|')})|(?:kapt|ksp)[A-Z][a-zA-Z0-9]*)`;
const CONFIGURATION_NAME = new RegExp(`^${CONFIGURATION}$`);
/**
* Configurations that name something other than an external module dependency,
* and so are neither parsed nor reported as unreadable.
*/
const IGNORED_CONFIGURATIONS = new Set(['classpath']);
/**
* `implementation project(':x')`, `implementation files(...)`: built from this
* repository or checked in, so there is no external declaration to go stale.
*/
const LOCAL_ARTIFACT = /^\(?\s*(?:project|files|fileTree|gradleApi|localGroovy)\s*\(/;
/**
* `implementation jscFlavor`: a Gradle variable resolved elsewhere in the build,
* whose value the manifest cannot key on.
*/
const GRADLE_VARIABLE = /^\(?\s*[A-Za-z_][A-Za-z0-9_]*\s*\)?$/;
/**
* `kapt { ... }`: a configuration block that happens to be named after a
* configuration, not a dependency declaration.
*/
const isDslBlock = (argument: string) => argument.startsWith('{');
/**
* `implementation "group:artifact:version"`, with the optional wrappers Gradle
* accepts around it. `\s*` spans newlines, so the coordinate may sit on its own
* line below the configuration.
*/
const STRING_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*(?:(?:platform|enforcedPlatform)\\s*\\()?\\s*['"]([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)`, 'gm');
/**
* `implementation group: 'g', name: 'a', version: 'v'`.
*/
const MAP_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*group\\s*:\\s*['"]([a-zA-Z0-9_.-]+)['"]\\s*,\\s*name\\s*:\\s*['"]([a-zA-Z0-9_.-]+)['"]`, 'gm');
/**
* `implementation okhttp`, where `okhttp` was assigned a coordinate literal
* earlier in the file. Without resolving these, hoisting a coordinate into a
* variable while tidying up versions would take it out of the check's sight.
*/
const VARIABLE_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\)?[ \\t]*$`, 'gm');
const STRING_CONSTANT = /^[ \t]*(?:def|final\s+String|String)?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]([a-zA-Z0-9_.:+-]+)['"]/gm;
const COORDINATE = /^([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)/;
const readBuildGradle = () => stripComments(fs.readFileSync(BUILD_GRADLE_PATH, 'utf8'), 'c');
/**
* Character offsets in `text` where a declaration pattern started matching,
* used to tell a line the parser read from one it skipped.
*/
function collectDeclarations(text: string): {coordinates: Set<string>; matchedOffsets: Set<number>} {
const coordinates = new Set<string>();
const matchedOffsets = new Set<number>();
for (const pattern of [STRING_DECLARATION, MAP_DECLARATION]) {
for (const match of text.matchAll(pattern)) {
coordinates.add(`${match.at(1)}:${match.at(2)}`);
matchedOffsets.add(match.index ?? 0);
}
}
const constants = new Map<string, string>();
for (const match of text.matchAll(STRING_CONSTANT)) {
constants.set(match.at(1) ?? '', match.at(2) ?? '');
}
for (const match of text.matchAll(VARIABLE_DECLARATION)) {
const coordinate = COORDINATE.exec(constants.get(match.at(1) ?? '') ?? '');
if (coordinate) {
coordinates.add(`${coordinate.at(1)}:${coordinate.at(2)}`);
matchedOffsets.add(match.index ?? 0);
}
}
return {coordinates, matchedOffsets};
}
/**
* Every `group:artifact` coordinate declared in android/app/build.gradle on a
* dependency configuration, including the ones named through a variable holding
* a coordinate literal. Local artifacts (`files(...)`, `project(...)`) are
* skipped: there is no external declaration to go stale.
*/
function readDeclaredDependencies(): string[] {
return [...collectDeclarations(readBuildGradle()).coordinates].sort();
}
/**
* Lines that declare a dependency in a form this parser cannot read: a version
* catalog accessor, an interpolated coordinate, anything new. Without this the
* parser would drop them silently and the check would report success over
* whatever subset it still understood, which is how a version-catalog migration
* turns the whole gate into a no-op.
*/
function findUnparsedDeclarations(): string[] {
const text = readBuildGradle();
const {matchedOffsets} = collectDeclarations(text);
const matchesPerLine = new Map<number, number>();
for (const offset of matchedOffsets) {
const line = text.slice(0, offset).split('\n').length - 1;
matchesPerLine.set(line, (matchesPerLine.get(line) ?? 0) + 1);
}
const unparsed: string[] = [];
for (const [index, line] of text.split('\n').entries()) {
// The separator after the name has to be whitespace or an opening paren.
// `implementation.exclude group: ...` and `implementation.transitive =
// false` configure a configuration rather than declaring anything.
const match = /^[ \t]*([A-Za-z][A-Za-z0-9]*)(?=[\s(]|$)(.*)$/.exec(line);
const configuration = match?.at(1);
const argument = (match?.at(2) ?? '').trim();
// Every coordinate-shaped literal on the line has to have been parsed,
// not just the first: the patterns are anchored to the line start, so a
// second declaration after a semicolon or a comma would otherwise be
// covered by the first one having matched.
const literals = (line.match(/['"][a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+/g) ?? []).length;
const parsed = matchesPerLine.get(index) ?? 0;
const readable = (parsed > 0 || LOCAL_ARTIFACT.test(argument) || GRADLE_VARIABLE.test(argument) || isDslBlock(argument)) && literals <= parsed;
if (configuration && !IGNORED_CONFIGURATIONS.has(configuration) && CONFIGURATION_NAME.test(configuration) && !readable) {
unparsed.push(`${RELATIVE_BUILD_GRADLE}:${index + 1}: ${line.trim()}`);
}
}
return unparsed;
}
function readFilesWithExtensions(extensions: Set<string>): string {
const contents: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) {
walk(path.join(dir, entry.name));
}
} else if (extensions.has(path.extname(entry.name))) {
const extension = path.extname(entry.name);
contents.push(stripComments(fs.readFileSync(path.join(dir, entry.name), 'utf8'), flavorForExtension(extension)));
}
}
};
walk(ANDROID_APP_DIR);
return contents.join('\n');
}
const readSources = () => readFilesWithExtensions(SOURCE_EXTENSIONS);
const readResources = () => readFilesWithExtensions(new Set(['.xml']));
const escapeForRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* How many times the project reaches a dependency, by the route its manifest
* entry declares: `packages` through Java and Kotlin imports, `resources`
* through anything in the XML — a view class name in a layout, a style parent,
* an attr. The `resources` match is bounded on both sides so that a name cannot
* be satisfied by being the tail of a longer, unrelated one.
*/
function countReferences({sources, resources}: SearchContext, entry: GradleUsageEntry): number {
if (entry.usage === 'packages') {
return entry.packages.reduce((total, prefix) => total + (sources.match(new RegExp(`^import\\s+(?:static\\s+)?${escapeForRegExp(prefix)}[.;\\s]`, 'gm'))?.length ?? 0), 0);
}
if (entry.usage === 'resources') {
return entry.resources.reduce((total, name) => total + (resources.match(new RegExp(`(?<![\\w.$])${escapeForRegExp(name)}(?![\\w$])`, 'g'))?.length ?? 0), 0);
}
return 0;
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
/**
* A manifest entry is trusted only once its shape is verified; the check reports
* the human-facing problems (a missing reason, an unusable date) separately.
*/
function isGradleUsageEntry(value: unknown): value is GradleUsageEntry {
if (!isRecord(value)) {
return false;
}
if (value.usage === 'packages') {
return Array.isArray(value.packages) && value.packages.every((prefix) => typeof prefix === 'string');
}
if (value.usage === 'resources') {
return Array.isArray(value.resources) && value.resources.every((name) => typeof name === 'string');
}
if (value.usage === 'exempt') {
return typeof value.reason === 'string' && typeof value.reviewed === 'string';
}
return false;
}
function readManifest(): GradleUsageManifest {
const parsed: unknown = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
if (!isRecord(parsed)) {
throw new Error(`${MANIFEST_PATH} must contain a JSON object.`);
}
const manifest: GradleUsageManifest = {};
for (const [coordinate, entry] of Object.entries<unknown>(parsed)) {
if (!isGradleUsageEntry(entry)) {
throw new Error(`${coordinate} in ${MANIFEST_PATH} must be a packages, resources or exempt entry.`);
}
manifest[coordinate] = entry;
}
return manifest;
}
function writeManifest(manifest: GradleUsageManifest) {
const sorted = Object.fromEntries(
Object.keys(manifest)
.sort()
.map((key) => [key, manifest[key]]),
);
fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(sorted, null, 4)}\n`);
}
export type {GradleUsageEntry, GradleUsageManifest, SearchContext};
export {
ANDROID_APP_DIR,
MANIFEST_PATH,
BUILD_GRADLE_PATH,
RELATIVE_BUILD_GRADLE,
readDeclaredDependencies,
findUnparsedDeclarations,
readSources,
readResources,
countReferences,
readManifest,
writeManifest,
};