@featurevisor/core
Version:
Core package of Featurevisor for Node.js usage
500 lines (389 loc) • 14.8 kB
text/typescript
// for use in node only
import * as path from "path";
import { getAttributeZodSchema } from "./attributeSchema";
import { getConditionsZodSchema } from "./conditionSchema";
import { getSegmentZodSchema } from "./segmentSchema";
import { getGroupZodSchema } from "./groupSchema";
import { getFeatureZodSchema } from "./featureSchema";
import { getTestsZodSchema } from "./testSchema";
import { checkForCircularDependencyInRequired } from "./checkCircularDependency";
import { checkForFeatureExceedingGroupSlotPercentage } from "./checkPercentageExceedingSlot";
import { printZodError } from "./printError";
import { Dependencies } from "../dependencies";
import { CLI_FORMAT_RED, CLI_FORMAT_BOLD_UNDERLINE } from "../tester/cliFormat";
import { Plugin } from "../cli";
export interface LintProjectOptions {
keyPattern?: string;
entityType?: string;
authors?: boolean;
}
const ENTITY_NAME_REGEX = /^[a-zA-Z0-9_\-./]+$/;
const ENTITY_NAME_REGEX_ERROR = "Names must be alphanumeric and can contain _, -, and .";
const ATTRIBUTE_NAME_REGEX = /^[a-zA-Z0-9_\-/]+$/;
const ATTRIBUTE_NAME_REGEX_ERROR = "Names must be alphanumeric and can contain _, and -";
async function getAuthorsOfEntity(datasource, entityType, entityKey): Promise<string[]> {
const entries = await datasource.listHistoryEntries(entityType, entityKey);
const authors: string[] = Array.from(new Set(entries.map((entry) => entry.author)));
return authors;
}
export async function lintProject(
deps: Dependencies,
options: LintProjectOptions = {},
): Promise<boolean> {
const { projectConfig, datasource } = deps;
let hasError = false;
function getFullPathFromKey(type: string, key: string, relative = false) {
const fileName = `${key}.${datasource.getExtension()}`;
let fullPath = "";
if (type === "attribute") {
fullPath = path.join(projectConfig.attributesDirectoryPath, fileName);
} else if (type === "segment") {
fullPath = path.join(projectConfig.segmentsDirectoryPath, fileName);
} else if (type === "feature") {
fullPath = path.join(projectConfig.featuresDirectoryPath, fileName);
} else if (type === "group") {
fullPath = path.join(projectConfig.groupsDirectoryPath, fileName);
} else if (type === "test") {
fullPath = path.join(projectConfig.testsDirectoryPath, fileName);
} else {
throw new Error(`Unknown type: ${type}`);
}
if (relative) {
fullPath = path.relative(process.cwd(), fullPath);
}
return fullPath;
}
const keyPattern = options.keyPattern ? new RegExp(options.keyPattern) : null;
if (keyPattern) {
console.log("");
console.log(`Linting only keys matching pattern: ${keyPattern}`);
console.log("");
}
// lint attributes
const attributes = await datasource.listAttributes();
const attributeZodSchema = getAttributeZodSchema();
if (!options.entityType || options.entityType === "attribute") {
const filteredKeys = !keyPattern
? attributes
: attributes.filter((key) => keyPattern.test(key));
if (filteredKeys.length > 0) {
console.log(`Linting ${filteredKeys.length} attributes...\n`);
}
for (const key of filteredKeys) {
const fullPath = getFullPathFromKey("attribute", key);
if (!ATTRIBUTE_NAME_REGEX.test(key)) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "attribute", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log(CLI_FORMAT_RED, ` => Error: Invalid name: "${key}"`);
console.log(CLI_FORMAT_RED, ` ${ATTRIBUTE_NAME_REGEX_ERROR}`);
console.log("");
hasError = true;
}
try {
const parsed = await datasource.readAttribute(key);
const result = attributeZodSchema.safeParse(parsed);
if (!result.success) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "attribute", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
if ("error" in result) {
printZodError(result.error);
}
hasError = true;
}
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "attribute", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log("");
console.log(e);
hasError = true;
}
}
}
const flattenedAttributes = await datasource.listFlattenedAttributes();
// lint segments
const segments = await datasource.listSegments();
const conditionsZodSchema = getConditionsZodSchema(
projectConfig,
flattenedAttributes as [string, ...string[]],
);
const segmentZodSchema = getSegmentZodSchema(projectConfig, conditionsZodSchema);
if (!options.entityType || options.entityType === "segment") {
const filteredKeys = !keyPattern ? segments : segments.filter((key) => keyPattern.test(key));
if (filteredKeys.length > 0) {
console.log(`Linting ${filteredKeys.length} segments...\n`);
}
for (const key of filteredKeys) {
const fullPath = getFullPathFromKey("segment", key);
if (!ENTITY_NAME_REGEX.test(key)) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "segment", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log(CLI_FORMAT_RED, ` => Error: Invalid name: "${key}"`);
console.log(CLI_FORMAT_RED, ` ${ENTITY_NAME_REGEX_ERROR}`);
console.log("");
hasError = true;
}
try {
const parsed = await datasource.readSegment(key);
const result = segmentZodSchema.safeParse(parsed);
if (!result.success) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "segment", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
if ("error" in result) {
printZodError(result.error);
}
hasError = true;
}
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "segment", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log("");
console.log(e);
hasError = true;
}
}
}
// lint features
const features = await datasource.listFeatures();
const featureZodSchema = getFeatureZodSchema(
projectConfig,
conditionsZodSchema,
flattenedAttributes as [string, ...string[]],
segments as [string, ...string[]],
features as [string, ...string[]],
);
if (!options.entityType || options.entityType === "feature") {
const filteredKeys = !keyPattern ? features : features.filter((key) => keyPattern.test(key));
if (filteredKeys.length > 0) {
console.log(`Linting ${filteredKeys.length} features...\n`);
}
for (const key of filteredKeys) {
const fullPath = getFullPathFromKey("feature", key);
if (!ENTITY_NAME_REGEX.test(key)) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "feature", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log(CLI_FORMAT_RED, ` => Error: Invalid name: "${key}"`);
console.log(CLI_FORMAT_RED, ` ${ENTITY_NAME_REGEX_ERROR}`);
console.log("");
hasError = true;
}
let parsed;
try {
parsed = await datasource.readFeature(key);
const result = featureZodSchema.safeParse(parsed);
if (!result.success) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "feature", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
if ("error" in result) {
printZodError(result.error);
}
hasError = true;
}
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "feature", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log("");
console.log(e);
hasError = true;
}
if (parsed && parsed.required) {
try {
await checkForCircularDependencyInRequired(datasource, key, parsed.required);
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "feature", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log(CLI_FORMAT_RED, ` => Error: ${e.message}`);
hasError = true;
}
}
}
}
// lint groups
const groups = await datasource.listGroups();
const groupZodSchema = getGroupZodSchema(projectConfig, datasource, features);
if (!options.entityType || options.entityType === "group") {
const filteredKeys = !keyPattern ? groups : groups.filter((key) => keyPattern.test(key));
if (filteredKeys.length > 0) {
console.log(`Linting ${filteredKeys.length} groups...\n`);
}
for (const key of filteredKeys) {
const fullPath = getFullPathFromKey("group", key);
if (!ENTITY_NAME_REGEX.test(key)) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
console.log(CLI_FORMAT_RED, ` => Error: Invalid name: "${key}"`);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "group", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log(CLI_FORMAT_RED, ` ${ENTITY_NAME_REGEX_ERROR}`);
console.log("");
hasError = true;
}
let parsed;
try {
parsed = await datasource.readGroup(key);
const result = groupZodSchema.safeParse(parsed);
if (!result.success) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "group", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
if ("error" in result) {
printZodError(result.error);
}
hasError = true;
}
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "group", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log("");
console.log(e);
hasError = true;
}
if (parsed) {
try {
await checkForFeatureExceedingGroupSlotPercentage(datasource, parsed, features);
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
console.log(CLI_FORMAT_RED, ` => Error: ${e.message}`);
hasError = true;
}
}
}
}
// @TODO: feature cannot exist in multiple groups
// lint tests
const tests = await datasource.listTests();
const testsZodSchema = getTestsZodSchema(
projectConfig,
features as [string, ...string[]],
segments as [string, ...string[]],
);
if (!options.entityType || options.entityType === "test") {
const filteredKeys = !keyPattern ? tests : tests.filter((key) => keyPattern.test(key));
if (filteredKeys.length > 0) {
console.log(`Linting ${filteredKeys.length} tests...\n`);
}
for (const key of filteredKeys) {
const fullPath = getFullPathFromKey("test", key);
if (!ENTITY_NAME_REGEX.test(key)) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "test", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log(CLI_FORMAT_RED, ` => Error: Invalid name: "${key}"`);
console.log(CLI_FORMAT_RED, ` ${ENTITY_NAME_REGEX_ERROR}`);
console.log("");
hasError = true;
}
try {
const parsed = await datasource.readTest(key);
const result = testsZodSchema.safeParse(parsed);
if (!result.success) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "test", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
if ("error" in result) {
printZodError(result.error);
process.exit(1);
}
hasError = true;
}
} catch (e) {
console.log(CLI_FORMAT_BOLD_UNDERLINE, fullPath);
if (options.authors) {
const authors = await getAuthorsOfEntity(datasource, "test", key);
console.log(` Authors: ${authors.join(", ")}\n`);
}
console.log("");
console.log(e);
hasError = true;
}
}
}
return hasError;
}
export const lintPlugin: Plugin = {
command: "lint",
handler: async function (options) {
const { rootDirectoryPath, projectConfig, datasource, parsed } = options;
const hasError = await lintProject(
{
rootDirectoryPath,
projectConfig,
datasource,
options: parsed,
},
{
keyPattern: parsed.keyPattern,
entityType: parsed.entityType,
authors: parsed.authors,
},
);
if (hasError) {
return false;
}
},
examples: [
{
command: "lint",
description: "lint all entities",
},
{
command: "lint --entityType=feature",
description: "lint only features",
},
{
command: "lint --entityType=segment",
description: "lint only segments",
},
{
command: "lint --entityType=group",
description: "lint only groups",
},
{
command: "lint --entityType=test",
description: "lint only tests",
},
{
command: 'lint --keyPattern="abc"',
description: `lint only entities with keys containing "abc"`,
},
],
};