mirror of
https://github.com/dromara/hertzbeat.git
synced 2026-10-08 09:29:01 +00:00
546 lines
20 KiB
JavaScript
546 lines
20 KiB
JavaScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one or more
|
|
* contributor license agreements. See the NOTICE file distributed with
|
|
* this work for additional information regarding copyright ownership.
|
|
* The ASF licenses this file to You under the Apache License, Version 2.0
|
|
* (the "License"); you may not use this file except in compliance with
|
|
* the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { dirname, extname, isAbsolute, join, normalize, posix, relative, sep } from 'node:path';
|
|
import ts from 'typescript';
|
|
|
|
const featureDebtRules = Object.freeze({
|
|
moduleSize: 'feature-module-size',
|
|
primitiveParser: 'primitive-wire-parser',
|
|
inlineQueryKey: 'inline-query-key',
|
|
directBrowserTransport: 'direct-browser-transport',
|
|
rawCssColor: 'feature-css-raw-color',
|
|
genericHooksFile: 'generic-hooks-file',
|
|
pageDomFormParsing: 'page-dom-form-parsing',
|
|
publicFeatureRootFile: 'public-feature-root-file',
|
|
presentationApiDependency: 'presentation-api-dependency',
|
|
modelApiDependency: 'model-api-dependency',
|
|
crossFeatureInternalDependency: 'cross-feature-internal-dependency'
|
|
});
|
|
|
|
const knownRules = new Set(Object.values(featureDebtRules));
|
|
const identityRules = new Set([
|
|
featureDebtRules.presentationApiDependency,
|
|
featureDebtRules.modelApiDependency,
|
|
featureDebtRules.crossFeatureInternalDependency
|
|
]);
|
|
const baselinePath = join('scripts', 'feature-debt-baseline.json');
|
|
const sourceExtensions = new Set(['.ts', '.tsx', '.css']);
|
|
const primitiveParserNames = new Set([
|
|
'array',
|
|
'boolean',
|
|
'enumValue',
|
|
'integer',
|
|
'number',
|
|
'object',
|
|
'record',
|
|
'string',
|
|
'stringArray',
|
|
'text'
|
|
]);
|
|
const queryClientMethodsWithDirectKey = new Set([
|
|
'getQueryData',
|
|
'getQueryDefaults',
|
|
'getQueryState',
|
|
'setQueryData',
|
|
'setQueryDefaults'
|
|
]);
|
|
const browserTransportNames = new Set(['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest']);
|
|
const browserGlobalNames = new Set(['globalThis', 'self', 'window']);
|
|
const domFormReaderNames = new Set(['readFormNumber', 'readFormValue']);
|
|
const domFormTypeNames = new Set(['FormEvent', 'HTMLFormElement']);
|
|
|
|
export function checkFeatureConventions(projectRoot) {
|
|
const violations = collectFeatureViolations(projectRoot);
|
|
const { entries, failures } = readAndValidateBaseline(projectRoot);
|
|
const violationsByKey = new Map(violations.map(violation => [violationKey(violation), violation]));
|
|
const validEntries = entries.filter(entry => !entry.invalid);
|
|
|
|
for (const entry of validEntries) {
|
|
const violation = violationsByKey.get(violationKey(entry));
|
|
if (!violation) {
|
|
failures.push(`stale baseline entry: ${entry.rule} at ${entry.path}`);
|
|
continue;
|
|
}
|
|
if (violation.actual > entry.allowedMax) {
|
|
failures.push(
|
|
`baseline exceeded for ${entry.rule} at ${entry.path}: actual ${violation.actual} exceeds allowedMax ${entry.allowedMax}`
|
|
);
|
|
} else if (violation.actual < entry.allowedMax) {
|
|
failures.push(
|
|
`stale baseline ceiling for ${entry.rule} at ${entry.path}: actual ${violation.actual} is below allowedMax ${entry.allowedMax}`
|
|
);
|
|
}
|
|
violationsByKey.delete(violationKey(entry));
|
|
}
|
|
|
|
failures.push(...[...violationsByKey.values()].map(formatViolation));
|
|
return failures;
|
|
}
|
|
|
|
function collectFeatureViolations(projectRoot) {
|
|
const featureRoot = join(projectRoot, 'src', 'features');
|
|
if (!existsSync(featureRoot)) return [];
|
|
const observations = walk(featureRoot).flatMap(path => inspectFeatureFile(path, join(projectRoot, 'src')));
|
|
const byKey = new Map();
|
|
for (const item of observations) {
|
|
const key = violationKey(item);
|
|
const current = byKey.get(key);
|
|
byKey.set(key, current ? { ...current, actual: current.actual + item.actual } : item);
|
|
}
|
|
return [...byKey.values()];
|
|
}
|
|
|
|
function inspectFeatureFile(path, sourceRoot) {
|
|
if (!sourceExtensions.has(extname(path)) || isTest(path)) return [];
|
|
const normalizedPath = relative(sourceRoot, path).split(sep).join('/');
|
|
const source = readFileSync(path, 'utf8');
|
|
const observations = [];
|
|
const syntaxCounts = extname(path) === '.css' ? undefined : countTypeScriptSyntax(source, path);
|
|
if (normalizedPath.split('/').includes('hooks')) {
|
|
observations.push(observation(featureDebtRules.genericHooksFile, normalizedPath, 1));
|
|
}
|
|
if (isPublicFeatureRootFile(path, normalizedPath)) {
|
|
observations.push(observation(featureDebtRules.publicFeatureRootFile, normalizedPath, 1));
|
|
}
|
|
const limit = featureModuleLimit(normalizedPath);
|
|
if (limit !== undefined) {
|
|
const actual = countSourceLines(source, extname(path));
|
|
if (actual > limit) observations.push(observation(featureDebtRules.moduleSize, normalizedPath, actual, limit));
|
|
}
|
|
if (isFeatureApiOrModel(normalizedPath)) {
|
|
addCountObservation(
|
|
observations,
|
|
featureDebtRules.primitiveParser,
|
|
normalizedPath,
|
|
syntaxCounts?.primitiveParsers ?? 0
|
|
);
|
|
}
|
|
if (isFeaturePage(normalizedPath)) {
|
|
addCountObservation(
|
|
observations,
|
|
featureDebtRules.pageDomFormParsing,
|
|
normalizedPath,
|
|
syntaxCounts?.pageDomFormParsing ?? 0
|
|
);
|
|
}
|
|
addCountObservation(
|
|
observations,
|
|
featureDebtRules.inlineQueryKey,
|
|
normalizedPath,
|
|
syntaxCounts?.inlineQueryKeys ?? 0
|
|
);
|
|
addCountObservation(
|
|
observations,
|
|
featureDebtRules.directBrowserTransport,
|
|
normalizedPath,
|
|
syntaxCounts?.directBrowserTransports ?? 0
|
|
);
|
|
if (extname(path) === '.css') {
|
|
const sourceWithoutComments = stripCssComments(source);
|
|
addCountObservation(
|
|
observations,
|
|
featureDebtRules.rawCssColor,
|
|
normalizedPath,
|
|
[...sourceWithoutComments.matchAll(/#[0-9a-f]{3,8}\b|\b(?:rgb|hsl)a?\s*\(/gi)].length
|
|
);
|
|
} else {
|
|
observations.push(...collectDependencyObservations(source, path, normalizedPath));
|
|
}
|
|
return observations;
|
|
}
|
|
|
|
function observation(rule, path, actual, limit = 0, identity) {
|
|
return { rule, path, actual, limit, ...(identity ? { identity } : {}) };
|
|
}
|
|
|
|
function addCountObservation(observations, rule, path, actual) {
|
|
if (actual > 0) observations.push(observation(rule, path, actual));
|
|
}
|
|
|
|
export function containsPrimitiveParserHelper(source, path, parserNames = primitiveParserNames, options = {}) {
|
|
return countTypeScriptSyntax(source, path, parserNames, options).primitiveParsers > 0;
|
|
}
|
|
|
|
function countTypeScriptSyntax(
|
|
source,
|
|
path,
|
|
parserNames = primitiveParserNames,
|
|
{ includeVariableDeclarations = false } = {}
|
|
) {
|
|
const sourceFile = ts.createSourceFile(
|
|
path,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
extname(path) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
);
|
|
const counts = {
|
|
primitiveParsers: 0,
|
|
inlineQueryKeys: 0,
|
|
directBrowserTransports: 0,
|
|
pageDomFormParsing: 0
|
|
};
|
|
const visit = node => {
|
|
if (ts.isFunctionDeclaration(node) && node.name && parserNames.has(node.name.text)) {
|
|
counts.primitiveParsers += 1;
|
|
}
|
|
if (
|
|
includeVariableDeclarations &&
|
|
ts.isVariableDeclaration(node) &&
|
|
ts.isIdentifier(node.name) &&
|
|
parserNames.has(node.name.text) &&
|
|
node.initializer &&
|
|
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
|
|
) {
|
|
counts.primitiveParsers += 1;
|
|
}
|
|
if (isInlineQueryKey(node)) counts.inlineQueryKeys += 1;
|
|
if (isDirectBrowserTransport(node)) counts.directBrowserTransports += 1;
|
|
if (isPageDomFormParsing(node)) counts.pageDomFormParsing += 1;
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
return counts;
|
|
}
|
|
|
|
function isDirectBrowserTransport(node) {
|
|
if (!ts.isCallExpression(node) && !ts.isNewExpression(node)) return false;
|
|
return directBrowserTransportName(node.expression) !== undefined;
|
|
}
|
|
|
|
function directBrowserTransportName(expression) {
|
|
if (ts.isIdentifier(expression) && browserTransportNames.has(expression.text)) return expression.text;
|
|
if (!ts.isPropertyAccessExpression(expression) && !ts.isElementAccessExpression(expression)) return undefined;
|
|
if (!ts.isIdentifier(expression.expression) || !browserGlobalNames.has(expression.expression.text)) return undefined;
|
|
|
|
const memberName = browserGlobalMemberName(expression);
|
|
return memberName !== undefined && browserTransportNames.has(memberName) ? memberName : undefined;
|
|
}
|
|
|
|
function browserGlobalMemberName(expression) {
|
|
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
if (ts.isStringLiteralLike(expression.argumentExpression)) return expression.argumentExpression.text;
|
|
return undefined;
|
|
}
|
|
|
|
function isPageDomFormParsing(node) {
|
|
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
const name = directOrGlobalMemberName(node.expression);
|
|
if (name === 'FormData' || (name !== undefined && domFormReaderNames.has(name))) return true;
|
|
}
|
|
if (ts.isTypeReferenceNode(node) && domFormTypeNames.has(rightmostTypeName(node.typeName))) return true;
|
|
return ts.isPropertyAccessExpression(node) && node.name.text === 'currentTarget';
|
|
}
|
|
|
|
function directOrGlobalMemberName(expression) {
|
|
if (ts.isIdentifier(expression)) return expression.text;
|
|
if (!ts.isPropertyAccessExpression(expression) && !ts.isElementAccessExpression(expression)) return undefined;
|
|
if (!ts.isIdentifier(expression.expression) || !browserGlobalNames.has(expression.expression.text)) return undefined;
|
|
return browserGlobalMemberName(expression);
|
|
}
|
|
|
|
function rightmostTypeName(name) {
|
|
return ts.isIdentifier(name) ? name.text : name.right.text;
|
|
}
|
|
|
|
function isInlineQueryKey(node) {
|
|
if (ts.isPropertyAssignment(node) || ts.isPropertyDeclaration(node)) {
|
|
return (
|
|
propertyName(node.name) === 'queryKey' &&
|
|
node.initializer !== undefined &&
|
|
ts.isArrayLiteralExpression(node.initializer)
|
|
);
|
|
}
|
|
if (!ts.isCallExpression(node)) return false;
|
|
const methodName = calledMethodName(node.expression);
|
|
const firstArgument = node.arguments[0];
|
|
return (
|
|
methodName !== null &&
|
|
queryClientMethodsWithDirectKey.has(methodName) &&
|
|
firstArgument !== undefined &&
|
|
ts.isArrayLiteralExpression(firstArgument)
|
|
);
|
|
}
|
|
|
|
function calledMethodName(expression) {
|
|
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
if (ts.isElementAccessExpression(expression) && ts.isStringLiteral(expression.argumentExpression)) {
|
|
return expression.argumentExpression.text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectDependencyObservations(source, path, importerPath) {
|
|
const sourceFile = ts.createSourceFile(
|
|
path,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
extname(path) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
);
|
|
const observations = [];
|
|
const importerSegments = importerPath.split('/');
|
|
const importerFeature = importerSegments[1];
|
|
const isFeatureRootPage = importerSegments.length === 3 && /-page\.tsx?$/.test(importerSegments[2] ?? '');
|
|
const isPresentation =
|
|
isFeatureRootPage || importerSegments.includes('components') || importerSegments.includes('pages');
|
|
const isModel = importerSegments.includes('model');
|
|
|
|
for (const specifier of collectModuleSpecifiers(sourceFile)) {
|
|
const target = resolveSourceImport(importerPath, specifier);
|
|
if (!target?.startsWith('features/')) continue;
|
|
const targetSegments = target.split('/');
|
|
const targetFeature = targetSegments[1];
|
|
const targetsApi = targetSegments.includes('api');
|
|
if (isPresentation && targetsApi) {
|
|
observations.push(observation(featureDebtRules.presentationApiDependency, importerPath, 1, 0, target));
|
|
}
|
|
if (isModel && targetsApi) {
|
|
observations.push(observation(featureDebtRules.modelApiDependency, importerPath, 1, 0, target));
|
|
}
|
|
if (targetFeature !== importerFeature && target !== `features/${targetFeature}`) {
|
|
observations.push(observation(featureDebtRules.crossFeatureInternalDependency, importerPath, 1, 0, target));
|
|
}
|
|
}
|
|
return observations;
|
|
}
|
|
|
|
function collectModuleSpecifiers(sourceFile) {
|
|
const specifiers = [];
|
|
const visit = node => {
|
|
if (
|
|
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
node.moduleSpecifier &&
|
|
ts.isStringLiteralLike(node.moduleSpecifier)
|
|
) {
|
|
specifiers.push(node.moduleSpecifier.text);
|
|
}
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
node.arguments.length === 1 &&
|
|
ts.isStringLiteralLike(node.arguments[0]) &&
|
|
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
|
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
|
|
) {
|
|
specifiers.push(node.arguments[0].text);
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sourceFile);
|
|
return specifiers;
|
|
}
|
|
|
|
function resolveSourceImport(importerPath, specifier) {
|
|
let target;
|
|
if (specifier.startsWith('@/')) target = specifier.slice(2);
|
|
else if (specifier.startsWith('.')) target = posix.normalize(posix.join(posix.dirname(importerPath), specifier));
|
|
else return undefined;
|
|
return target.replace(/\.(?:[cm]?[jt]sx?)$/, '').replace(/\/index$/, '');
|
|
}
|
|
|
|
function propertyName(name) {
|
|
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
|
|
return name.text;
|
|
}
|
|
if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) return name.expression.text;
|
|
return undefined;
|
|
}
|
|
|
|
function featureModuleLimit(path) {
|
|
if (/-page\.[jt]sx?$/.test(path) || /\/pages\/[^/]+\.[jt]sx?$/.test(path)) return 150;
|
|
if (/\/(?:components|controller)\//.test(path) || /(?:^|\/)[^/]+-controller\.[jt]sx?$/.test(path)) return 200;
|
|
if (/\/(?:api|model)\//.test(path)) return 250;
|
|
if (/^features\/[^/]+\/index\.[jt]sx?$/.test(path)) return 100;
|
|
return undefined;
|
|
}
|
|
|
|
function isFeatureApiOrModel(path) {
|
|
return /\/(?:api|model)\//.test(`/${path}`) || /(?:^|\/)[^/]+-(?:api|model)\.[cm]?[jt]sx?$/.test(path);
|
|
}
|
|
|
|
function isFeaturePage(path) {
|
|
return /\/pages\//.test(`/${path}`) || /(?:^|\/)[^/]+-page\.[cm]?[jt]sx?$/.test(path);
|
|
}
|
|
|
|
function isPublicFeatureRootFile(path, normalizedPath) {
|
|
if (!/^features\/[^/]+\/[^/]+\.[cm]?[jt]sx?$/.test(normalizedPath)) return false;
|
|
if (/\/index\.[cm]?[jt]sx?$/.test(normalizedPath)) return false;
|
|
return existsSync(join(dirname(path), 'index.ts')) || existsSync(join(dirname(path), 'index.tsx'));
|
|
}
|
|
|
|
export function countSourceLines(source, extension) {
|
|
const withoutComments =
|
|
extension === '.css' ? stripCssComments(source) : stripTypeScriptComments(source, extension === '.tsx');
|
|
return withoutComments.split(/\r?\n/).filter(line => line.trim()).length;
|
|
}
|
|
|
|
function stripCssComments(source) {
|
|
return source.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\r\n]/g, ' '));
|
|
}
|
|
|
|
function stripTypeScriptComments(source, jsx) {
|
|
const scanner = ts.createScanner(
|
|
ts.ScriptTarget.Latest,
|
|
false,
|
|
jsx ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard,
|
|
source
|
|
);
|
|
const ranges = [];
|
|
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia) {
|
|
ranges.push([scanner.getTokenPos(), scanner.getTextPos()]);
|
|
}
|
|
}
|
|
let cursor = 0;
|
|
return (
|
|
ranges
|
|
.map(([start, end]) => {
|
|
const unchanged = source.slice(cursor, start);
|
|
const comment = source.slice(start, end).replace(/[^\r\n]/g, ' ');
|
|
cursor = end;
|
|
return unchanged + comment;
|
|
})
|
|
.join('') + source.slice(cursor)
|
|
);
|
|
}
|
|
|
|
function readAndValidateBaseline(projectRoot) {
|
|
const path = join(projectRoot, baselinePath);
|
|
if (!existsSync(path)) return { entries: [], failures: [] };
|
|
let baseline;
|
|
try {
|
|
baseline = JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch {
|
|
return { entries: [], failures: [`${baselinePath}: baseline must be valid JSON`] };
|
|
}
|
|
if (!baseline || baseline.version !== 1 || !Array.isArray(baseline.entries)) {
|
|
return { entries: [], failures: [`${baselinePath}: expected { version: 1, entries: [] }`] };
|
|
}
|
|
const failures = [];
|
|
const seen = new Set();
|
|
const entries = baseline.entries.map((entry, index) =>
|
|
validateBaselineEntry(projectRoot, entry, index, seen, failures)
|
|
);
|
|
return { entries, failures };
|
|
}
|
|
|
|
function validateBaselineEntry(projectRoot, value, index, seen, failures) {
|
|
const label = `${baselinePath} entry ${index + 1}`;
|
|
const entry = value && typeof value === 'object' ? { ...value, invalid: false } : { invalid: true };
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
failures.push(`${label}: baseline entry must be an object`);
|
|
return entry;
|
|
}
|
|
if (!knownRules.has(entry.rule)) {
|
|
failures.push(`${label}: unknown baseline rule '${String(entry.rule)}'`);
|
|
entry.invalid = true;
|
|
}
|
|
if (!isExactFeaturePath(entry.path)) {
|
|
failures.push(`${label}: baseline path must be exact: ${String(entry.path)}`);
|
|
entry.invalid = true;
|
|
} else if (!existsSync(join(projectRoot, 'src', entry.path))) {
|
|
failures.push(`${label}: baseline path does not exist: ${entry.path}`);
|
|
entry.invalid = true;
|
|
}
|
|
if (!Number.isSafeInteger(entry.allowedMax) || entry.allowedMax <= 0) {
|
|
failures.push(`${label}: allowedMax must be a positive safe integer`);
|
|
entry.invalid = true;
|
|
}
|
|
if (identityRules.has(entry.rule) && !isExactDependencyIdentity(entry.identity)) {
|
|
failures.push(`${label}: dependency identity must be exact: ${String(entry.identity)}`);
|
|
entry.invalid = true;
|
|
} else if (!identityRules.has(entry.rule) && entry.identity !== undefined) {
|
|
failures.push(`${label}: rule '${String(entry.rule)}' does not accept an identity`);
|
|
entry.invalid = true;
|
|
}
|
|
const key = violationKey(entry);
|
|
if (seen.has(key)) {
|
|
failures.push(`${label}: duplicate baseline entry for ${entry.rule} at ${entry.path}`);
|
|
entry.invalid = true;
|
|
}
|
|
seen.add(key);
|
|
return entry;
|
|
}
|
|
|
|
function isExactFeaturePath(path) {
|
|
return (
|
|
typeof path === 'string' &&
|
|
path.startsWith('features/') &&
|
|
!isAbsolute(path) &&
|
|
!path.includes('\\') &&
|
|
normalize(path).split(sep).join('/') === path &&
|
|
!/[?*[\]{}!]/.test(path)
|
|
);
|
|
}
|
|
|
|
function violationKey(value) {
|
|
return `${value.rule}:${value.path}:${value.identity ?? ''}`;
|
|
}
|
|
|
|
function isExactDependencyIdentity(identity) {
|
|
return (
|
|
typeof identity === 'string' &&
|
|
identity.startsWith('features/') &&
|
|
!isAbsolute(identity) &&
|
|
!identity.includes('\\') &&
|
|
posix.normalize(identity) === identity &&
|
|
!/[?*[\]{}!]/.test(identity)
|
|
);
|
|
}
|
|
|
|
function formatViolation(violation) {
|
|
switch (violation.rule) {
|
|
case featureDebtRules.moduleSize:
|
|
return `${violation.path}: ${violation.actual} lines exceeds ${violation.limit}`;
|
|
case featureDebtRules.primitiveParser:
|
|
return `${violation.path}: use runtime schemas instead of local primitive wire parsers`;
|
|
case featureDebtRules.inlineQueryKey:
|
|
return `${violation.path}: use the feature Query Key factory`;
|
|
case featureDebtRules.directBrowserTransport:
|
|
return `${violation.path}: direct browser transport is forbidden; use core/http`;
|
|
case featureDebtRules.rawCssColor:
|
|
return `${violation.path}: use shared semantic color tokens`;
|
|
case featureDebtRules.genericHooksFile:
|
|
return `${violation.path}: generic feature hooks directories are forbidden; move orchestration to controller`;
|
|
case featureDebtRules.pageDomFormParsing:
|
|
return `${violation.path}: pages cannot parse DOM form state; move form ownership into a component and typed model`;
|
|
case featureDebtRules.publicFeatureRootFile:
|
|
return `${violation.path}: public features cannot keep production source at the feature root`;
|
|
case featureDebtRules.presentationApiDependency:
|
|
return `${violation.path}: presentation cannot depend on feature API ${violation.identity}`;
|
|
case featureDebtRules.modelApiDependency:
|
|
return `${violation.path}: model cannot depend on feature API ${violation.identity}`;
|
|
case featureDebtRules.crossFeatureInternalDependency:
|
|
return `${violation.path}: cross-feature imports must use the target public API instead of ${violation.identity}`;
|
|
default:
|
|
return `${violation.path}: unknown feature convention violation`;
|
|
}
|
|
}
|
|
|
|
function walk(directory) {
|
|
return readdirSync(directory).flatMap(entry => {
|
|
const path = join(directory, entry);
|
|
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
});
|
|
}
|
|
|
|
function isTest(path) {
|
|
return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path);
|
|
}
|