Files
clean-architecture-frontend…/scripts/lib/package-script-graph.ts
T

995 lines
31 KiB
TypeScript

type PackageManager = "pnpm" | "npm" | "yarn";
type ManagerParseResult = Readonly<{
dependencies: readonly string[];
unsafeLifecycle: boolean;
unsupportedManagerSyntax: boolean;
}>;
type SuppressionState = {
effective: boolean | undefined;
contradictory: boolean;
malformed: boolean;
};
type ShellNpmScopeEnvironmentState = {
autoExport: boolean;
forbidden: boolean;
uncertain: boolean;
};
type ShellCommandPrefix = Readonly<{
assignments: readonly Readonly<{
dynamicName: boolean;
name: string | null;
}>[];
commandIndex: number;
uncertain: boolean;
}>;
type EnvironmentCommandPrefix = Readonly<{
assignmentNames: readonly string[];
uncertain: boolean;
}>;
type TokenizedShellSegment = Readonly<{
tokens: readonly string[];
expansionTokens: readonly boolean[];
}>;
const managerNames = new Set<PackageManager>(["pnpm", "npm", "yarn"]);
const managerOptionsWithValue = new Set([
"-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter",
"--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix",
"--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir",
]);
const managerBooleanOptions = new Set([
"--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent",
"--use-stderr", "--verbose", "-g", "-s",
]);
const npmScriptDispatchBooleanOptions = new Set([
"--foreground-scripts", "--if-present", "--ignore-scripts",
]);
const npmScriptDispatchScopeOptions = new Set([
"--prefix", "--workspace", "--workspaces",
]);
const npmDispatchScopeEnvironmentNames = new Set([
"npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig",
"npm_config_workspace", "npm_config_workspaces",
]);
const npmIndirectConfigAuthorityOptions = new Set([
"--globalconfig", "--userconfig",
]);
const manifestScopeOptions: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]),
npm: new Set(["--prefix", "--workspace"]),
yarn: new Set(["--cwd"]),
};
const lifecycleMutationCommands = new Set([
"add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish",
"rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade",
]);
const managerBuiltinAliases: Readonly<
Record<PackageManager, ReadonlyMap<string, string>>
> = {
pnpm: new Map([["ln", "link"]]),
npm: new Map(),
yarn: new Map(),
};
const unsupportedBuiltinDispatchers: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["dlx", "exec"]),
npm: new Set(["exec"]),
yarn: new Set(["dlx", "exec", "workspace", "workspaces"]),
};
const lifecycleBooleanOptions: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set([
"--dry-run", "--force", "--frozen-lockfile", "--lockfile-only",
"--no-optional", "--prefer-frozen-lockfile", "--recursive",
"--workspace-root", "-D", "-P", "-r", "-w",
]),
npm: new Set([
"--audit", "--dry-run", "--force", "--foreground-scripts", "--fund",
"--package-lock-only",
]),
yarn: new Set([
"--check-cache", "--frozen-lockfile", "--ignore-engines",
"--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds",
"--no-lockfile", "--non-interactive", "--pure-lockfile",
]),
};
const lifecycleOptionsWithValue: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]),
npm: new Set(["--include", "--install-strategy", "--omit"]),
yarn: new Set(["--mode", "--modules-folder", "--production"]),
};
const knownBuiltinCommands: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
pnpm: new Set([
"audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls",
"outdated", "root", "server", "setup", "store", "view", "why",
]),
npm: new Set([
"access", "audit", "bugs", "cache", "completion", "config", "diff", "docs",
"doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init",
"list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg",
"prefix", "profile", "query", "repo", "root", "search", "star", "stars",
"team", "token", "unstar", "version", "view", "whoami",
]),
yarn: new Set([
"cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info",
"npm", "plugin", "set", "stage", "version", "why",
]),
};
const exactBareSafeBuiltinCommands: Readonly<
Record<PackageManager, ReadonlySet<string>>
> = {
pnpm: new Set(["audit"]),
npm: new Set(["audit"]),
yarn: new Set(),
};
const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]);
export function validatePackageScriptGraph(
scripts: Readonly<Record<string, string>>,
entryScript: string,
): string[] {
const failures: string[] = [];
const visiting = new Set<string>();
const visited = new Set<string>();
const stack: string[] = [];
const visit = (scriptName: string): void => {
if (visiting.has(scriptName)) {
const start = stack.indexOf(scriptName);
failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`);
return;
}
if (visited.has(scriptName)) return;
const command = scripts[scriptName];
if (command === undefined) {
failures.push(`package script missing: ${scriptName}`);
return;
}
visiting.add(scriptName);
stack.push(scriptName);
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
failures.push(`${scriptName} must not invoke the CI gate runner`);
}
const parsed = parseManagerCommands(command, scripts);
if (parsed.unsupportedManagerSyntax) {
failures.push(`package script manager invocation is not safely parseable: ${scriptName}`);
}
for (const dependency of parsed.dependencies) {
if (dependency === "ci:gate") {
failures.push(`${scriptName} must not invoke ci:gate`);
} else if (!(dependency in scripts)) {
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
} else {
visit(dependency);
}
}
stack.pop();
visiting.delete(scriptName);
visited.add(scriptName);
};
visit(entryScript);
return [...new Set(failures)];
}
export function validateInstallScriptPolicy(
scripts: Readonly<Record<string, string>>,
entryScripts: readonly string[],
): string[] {
const failures: string[] = [];
const visited = new Set<string>();
const visit = (scriptName: string): void => {
if (visited.has(scriptName)) return;
visited.add(scriptName);
const command = scripts[scriptName];
if (command === undefined) {
failures.push(`package script missing: ${scriptName}`);
return;
}
const parsed = parseManagerCommands(command, scripts);
if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) {
failures.push(
`install-bearing package script must use --ignore-scripts: ${scriptName}`,
);
}
for (const dependency of parsed.dependencies) {
if (dependency !== "ci:gate") visit(dependency);
}
};
for (const entryScript of entryScripts) visit(entryScript);
return [...new Set(failures)];
}
export function validateNpmScopeEnvironment(
environment: Readonly<Record<string, string | undefined>>,
): string[] {
return Object.keys(environment)
.filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase()))
.map((name) => `npm scope environment is not allowed: ${name}`);
}
function parseManagerCommands(
command: string,
scripts: Readonly<Record<string, string>>,
): ManagerParseResult {
const tokenized = tokenizeShellSegments(command);
const dependencies: string[] = [];
let unsafeLifecycle = false;
let unsupportedManagerSyntax = false;
if (!tokenized) {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: containsManagerReference(command),
});
}
unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command);
const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = {
autoExport: false,
forbidden: false,
uncertain: false,
};
for (const segment of tokenized.segments) {
const { tokens, expansionTokens } = segment;
updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!;
if (token === "corepack") {
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
unsupportedManagerSyntax = true;
break;
}
const wrapped = tokens[index + 1];
if (!wrapped || !isPackageManager(wrapped)) {
unsupportedManagerSyntax = true;
break;
}
const parsed = parseManagerInvocation(
wrapped,
tokens,
expansionTokens,
index + 2,
scripts,
hasUnsafeNpmScopeEnvironment(
npmScopeEnvironmentState,
tokens,
expansionTokens,
index,
),
);
dependencies.push(...parsed.dependencies);
unsafeLifecycle ||= parsed.unsafeLifecycle;
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
break;
}
if (isPackageManager(token)) {
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
unsupportedManagerSyntax = true;
break;
}
const parsed = parseManagerInvocation(
token,
tokens,
expansionTokens,
index + 1,
scripts,
hasUnsafeNpmScopeEnvironment(
npmScopeEnvironmentState,
tokens,
expansionTokens,
index,
),
);
dependencies.push(...parsed.dependencies);
unsafeLifecycle ||= parsed.unsafeLifecycle;
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
break;
}
if (containsManagerReference(token)) {
unsupportedManagerSyntax = true;
break;
}
}
}
return Object.freeze({
dependencies: Object.freeze([...new Set(dependencies)]),
unsafeLifecycle,
unsupportedManagerSyntax,
});
}
function parseManagerInvocation(
manager: PackageManager,
tokens: readonly string[],
expansionTokens: readonly boolean[],
start: number,
scripts: Readonly<Record<string, string>>,
hasNpmScopeEnvironment: boolean,
): ManagerParseResult {
let cursor = start;
let changesManifestScope = false;
let hasNpmConfigAuthority = false;
let consumedManagerSyntax = false;
const suppression: SuppressionState = {
effective: undefined,
contradictory: false,
malformed: false,
};
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
consumedManagerSyntax = true;
const option = tokens[cursor]!;
const parsedSuppression = consumeSuppressionOption(
manager,
tokens,
cursor,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return unsupportedResult();
cursor = parsedSuppression.nextIndex;
continue;
}
const equals = option.indexOf("=");
const name = equals < 0 ? option : option.slice(0, equals);
if (managerOptionsWithValue.has(name)) {
changesManifestScope ||= manifestScopeOptions[manager].has(name);
hasNpmConfigAuthority ||=
manager === "npm" && npmIndirectConfigAuthorityOptions.has(name);
if (equals >= 0) {
if (option.slice(equals + 1).length === 0) return unsupportedResult();
} else {
cursor += 1;
if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) {
return unsupportedResult();
}
}
} else if (managerBooleanOptions.has(name)) {
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) {
return unsupportedResult();
}
} else if (option !== "--") {
return unsupportedResult();
}
cursor += 1;
}
const subcommand = tokens[cursor];
if (!subcommand) return unsupportedResult();
if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) {
return unsupportedResult();
}
const argumentsAfterCommand = tokens.slice(cursor + 1);
if (subcommand === "run" || subcommand === "run-script") {
const dependency = argumentsAfterCommand[0];
if (!dependency || dependency.startsWith("-")) return unsupportedResult();
if (changesManifestScope) return unsupportedResult();
if (
manager === "npm" &&
(expansionTokens.slice(start, cursor + 2).some(Boolean) ||
!areNpmScriptDispatchArgumentsSupported(
argumentsAfterCommand.slice(1),
expansionTokens.slice(cursor + 2),
suppression,
))
) {
return unsupportedResult();
}
return manager === "npm"
? npmScriptDependencyResult(dependency, scripts, suppression)
: dependencyResult(dependency);
}
const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand;
if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) {
return unsupportedResult();
}
if (lifecycleMutationCommands.has(canonicalSubcommand)) {
const lifecycleArgumentsSupported = parseLifecycleArguments(
manager,
argumentsAfterCommand,
suppression,
);
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle:
!lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression),
unsupportedManagerSyntax: !lifecycleArgumentsSupported,
});
}
if (knownBuiltinCommands[manager].has(canonicalSubcommand)) {
return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) &&
!consumedManagerSyntax &&
argumentsAfterCommand.length === 0
? emptyResult()
: unsupportedResult();
}
const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand);
const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && (
((manager === "pnpm" || manager === "yarn") && isKnownRootScript) ||
(manager === "npm" && npmImplicitScripts.has(subcommand))
);
if (supportsImplicit) {
if (changesManifestScope) return unsupportedResult();
if (
manager === "npm" &&
(expansionTokens.slice(start, cursor + 1).some(Boolean) ||
!areNpmScriptDispatchArgumentsSupported(
argumentsAfterCommand,
expansionTokens.slice(cursor + 1),
suppression,
))
) {
return unsupportedResult();
}
return manager === "npm"
? npmScriptDependencyResult(subcommand, scripts, suppression)
: dependencyResult(subcommand);
}
return unsupportedResult();
}
function areNpmScriptDispatchArgumentsSupported(
tokens: readonly string[],
expansionTokens: readonly boolean[],
suppression: SuppressionState,
): boolean {
let index = 0;
while (index < tokens.length) {
const token = tokens[index]!;
if (token === "--") return true;
if (expansionTokens[index]) return false;
if (!token.startsWith("-")) {
index += 1;
continue;
}
const parsedSuppression = consumeSuppressionOption(
"npm",
tokens,
index,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return false;
index = parsedSuppression.nextIndex;
continue;
}
const equals = token.indexOf("=");
const name = equals < 0 ? token : token.slice(0, equals);
const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token);
if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) {
return false;
}
if (!npmScriptDispatchBooleanOptions.has(name)) return false;
if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) {
return false;
}
index += 1;
}
return true;
}
function hasUnsafeNpmScopeEnvironment(
state: Readonly<ShellNpmScopeEnvironmentState>,
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
return state.forbidden || state.uncertain ||
hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex);
}
function hasUnsafeManagerCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
if (prefix.uncertain) return true;
let cursor = prefix.commandIndex;
if (cursor === commandIndex) return false;
if (cursor > commandIndex ||
!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) {
return true;
}
return parseEnvironmentCommandPrefix(
tokens,
expansionTokens,
cursor,
commandIndex,
).uncertain;
}
function hasUnsafeImmediateNpmScopeEnvironment(
tokens: readonly string[],
expansionTokens: readonly boolean[],
commandIndex: number,
): boolean {
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
if (prefix.uncertain || prefix.assignments.some(
({ name }) => name !== null && isNpmScopeEnvironmentName(name),
)) return true;
let cursor = prefix.commandIndex;
if (cursor === commandIndex) return false;
if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false;
const environmentPrefix = parseEnvironmentCommandPrefix(
tokens,
expansionTokens,
cursor,
commandIndex,
);
return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some(
(name) => isNpmScopeEnvironmentName(name),
);
}
function parseEnvironmentCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
start: number,
commandIndex: number,
): EnvironmentCommandPrefix {
const assignmentNames: string[] = [];
let cursor = start + 1;
let uncertain = false;
while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) {
const option = tokens[cursor]!;
if (expansionTokens[cursor]) uncertain = true;
if (option === "--") {
cursor += 1;
break;
}
if (option === "-i" || option === "--ignore-environment") {
cursor += 1;
continue;
}
if (option === "-u" || option === "--unset") {
cursor += 1;
if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) {
uncertain = true;
break;
}
uncertain ||= expansionTokens[cursor] ?? false;
cursor += 1;
continue;
}
if (/^--unset=.+/u.test(option)) {
cursor += 1;
continue;
}
uncertain = true;
cursor += 1;
}
while (cursor < commandIndex) {
const token = tokens[cursor]!;
if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) {
uncertain = true;
}
const assignmentName = parseEnvironmentAssignmentName(token);
if (assignmentName) assignmentNames.push(assignmentName);
else uncertain = true;
cursor += 1;
}
return Object.freeze({
assignmentNames: Object.freeze(assignmentNames),
uncertain,
});
}
function parseShellCommandPrefix(
tokens: readonly string[],
expansionTokens: readonly boolean[],
): ShellCommandPrefix {
const assignments: Array<{
dynamicName: boolean;
name: string | null;
}> = [];
let cursor = 0;
let uncertain = false;
while (cursor < tokens.length) {
const token = tokens[cursor]!;
const name = parseAssignmentName(token);
const dynamicName = hasDynamicAssignmentName(
token,
expansionTokens[cursor] ?? false,
);
if (!name && !dynamicName) break;
assignments.push({ dynamicName, name });
uncertain ||= dynamicName;
cursor += 1;
}
while (cursor < tokens.length) {
const wrapper = tokens[cursor];
if (expansionTokens[cursor]) {
uncertain = true;
break;
}
if (wrapper !== "command" && wrapper !== "exec") break;
cursor += 1;
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
const option = tokens[cursor]!;
if (option === "--") {
cursor += 1;
break;
}
if (wrapper === "command" && option === "-p") {
cursor += 1;
continue;
}
uncertain = true;
cursor += 1;
if (wrapper === "exec" && option === "-a" && cursor < tokens.length) {
cursor += 1;
}
}
}
if (expansionTokens[cursor]) uncertain = true;
return Object.freeze({
assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))),
commandIndex: cursor,
uncertain,
});
}
function updateShellNpmScopeEnvironmentState(
segment: TokenizedShellSegment,
state: ShellNpmScopeEnvironmentState,
): void {
const { tokens, expansionTokens } = segment;
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
state.uncertain ||= prefix.uncertain;
const command = tokens[prefix.commandIndex];
if (!command) {
for (const assignment of prefix.assignments) {
if (state.autoExport && assignment.name &&
isNpmScopeEnvironmentName(assignment.name)) {
state.forbidden = true;
}
}
return;
}
if (command === "eval" || command === "." || command === "source") {
state.uncertain = true;
return;
}
if (command === "unset" || command === "typeset" || command === "declare" ||
command === "local" || command === "readonly") {
state.uncertain = true;
return;
}
if (command === "set") {
if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true;
if (tokens.slice(prefix.commandIndex + 1).includes("+a")) {
state.autoExport = false;
state.uncertain = true;
}
return;
}
if (command === "export") {
let cursor = prefix.commandIndex + 1;
for (; cursor < tokens.length; cursor += 1) {
const token = tokens[cursor]!;
if (token === "--") continue;
if (token === "-n" || token.startsWith("-")) {
state.uncertain = true;
continue;
}
const assignmentName = parseAssignmentName(token);
const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null;
if (assignmentName || bareName) {
if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) {
state.forbidden = true;
}
} else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) ||
expansionTokens[cursor]) {
state.uncertain = true;
}
}
return;
}
}
function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean {
if (!token || hasExpansion) return false;
return token.split("/").at(-1) === "env";
}
function parseEnvironmentAssignmentName(token: string): string | null {
const equals = token.indexOf("=");
return equals > 0 ? token.slice(0, equals) : null;
}
function isNpmScopeEnvironmentName(name: string): boolean {
return npmDispatchScopeEnvironmentNames.has(name.toLowerCase());
}
function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean {
const equals = token.indexOf("=");
return hasExpansion && equals > 0 && parseAssignmentName(token) === null;
}
function parseAssignmentName(token: string): string | null {
return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null;
}
function parseLifecycleArguments(
manager: PackageManager,
tokens: readonly string[],
suppression: SuppressionState,
): boolean {
let cursor = 0;
while (cursor < tokens.length) {
const token = tokens[cursor]!;
if (!token.startsWith("-")) {
cursor += 1;
continue;
}
const parsedSuppression = consumeSuppressionOption(
manager,
tokens,
cursor,
suppression,
);
if (parsedSuppression.recognized) {
if (parsedSuppression.unsupported) return false;
cursor = parsedSuppression.nextIndex;
continue;
}
const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor);
if (parsedOption === null) return false;
cursor = parsedOption;
}
return true;
}
function consumeSuppressionOption(
manager: PackageManager,
tokens: readonly string[],
index: number,
state: SuppressionState,
): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> {
const token = tokens[index]!;
if (token === "--no-ignore-scripts") {
recordSuppression(state, false);
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const;
for (const prefix of equalsForms) {
if (!token.startsWith(prefix)) continue;
if (prefix.startsWith("--config.") && manager !== "pnpm") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
const raw = token.slice(prefix.length);
if (raw !== "true" && raw !== "false") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
recordSuppression(state, raw === "true");
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") {
return { recognized: false, unsupported: false, nextIndex: index };
}
if (token === "--config.ignore-scripts" && manager !== "pnpm") {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 1 };
}
const next = tokens[index + 1];
if (next === "true" || next === "false") {
const supportsSplitValue = manager === "npm" || manager === "pnpm";
if (!supportsSplitValue) {
state.malformed = true;
return { recognized: true, unsupported: true, nextIndex: index + 2 };
}
recordSuppression(state, next === "true");
return { recognized: true, unsupported: false, nextIndex: index + 2 };
}
recordSuppression(state, true);
return { recognized: true, unsupported: false, nextIndex: index + 1 };
}
function recordSuppression(
state: SuppressionState,
value: boolean,
): void {
if (state.effective !== undefined && state.effective !== value) {
state.contradictory = true;
}
state.effective = value;
}
function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean {
return state.effective === true && !state.contradictory && !state.malformed;
}
function consumeAllowedLifecycleOption(
manager: PackageManager,
tokens: readonly string[],
index: number,
): number | null {
const option = tokens[index]!;
const equals = option.indexOf("=");
const name = equals < 0 ? option : option.slice(0, equals);
const booleanOption =
managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name);
if (booleanOption) {
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null;
return index + 1;
}
const valuedOption =
managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name);
if (!valuedOption) return null;
if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null;
const value = tokens[index + 1];
if (!value || value.startsWith("-")) return null;
return index + 2;
}
function dependencyResult(dependency: string): ManagerParseResult {
return dependenciesResult([dependency]);
}
function npmScriptDependencyResult(
dependency: string,
scripts: Readonly<Record<string, string>>,
suppression: SuppressionState,
): ManagerParseResult {
if (hasEffectiveLifecycleSuppression(suppression)) {
return dependenciesResult([dependency]);
}
return dependenciesResult(
[`pre${dependency}`, dependency, `post${dependency}`]
.filter((scriptName) => scriptName === dependency || scriptName in scripts),
);
}
function dependenciesResult(dependencies: readonly string[]): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([...dependencies]),
unsafeLifecycle: false,
unsupportedManagerSyntax: false,
});
}
function emptyResult(): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: false,
});
}
function unsupportedResult(): ManagerParseResult {
return Object.freeze({
dependencies: Object.freeze([]),
unsafeLifecycle: false,
unsupportedManagerSyntax: true,
});
}
function isPackageManager(value: string): value is PackageManager {
return managerNames.has(value as PackageManager);
}
function containsManagerReference(value: string): boolean {
return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u
.test(value);
}
function tokenizeShellSegments(command: string): Readonly<{
segments: readonly TokenizedShellSegment[];
unsupportedControl: boolean;
}> | null {
const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [
{ tokens: [], expansionTokens: [] },
];
let token = "";
let tokenHasExpansion = false;
let quote: "'" | '"' | null = null;
let escaping = false;
let unsupportedControl = false;
const pushToken = (): void => {
if (token.length > 0) {
segments.at(-1)!.tokens.push(token);
segments.at(-1)!.expansionTokens.push(tokenHasExpansion);
}
token = "";
tokenHasExpansion = false;
};
const pushSegment = (): void => {
pushToken();
if (segments.at(-1)!.tokens.length > 0) {
segments.push({ tokens: [], expansionTokens: [] });
}
};
for (let index = 0; index < command.length; index += 1) {
const character = command[index]!;
if (escaping) {
token += character;
escaping = false;
continue;
}
if (character === "\\" && quote !== "'") {
escaping = true;
continue;
}
if (quote) {
if (character === quote) quote = null;
else {
if (quote === '"' && character === "$") tokenHasExpansion = true;
token += character;
}
continue;
}
if (character === "'" || character === '"') {
quote = character;
continue;
}
if (character === "`" || (character === "$" && command[index + 1] === "(")) {
unsupportedControl = true;
if (character === "$") tokenHasExpansion = true;
token += character;
continue;
}
if (character === "$" || character === "*" || character === "?" || character === "[") {
tokenHasExpansion = true;
}
if (character === "#") {
unsupportedControl = true;
pushToken();
while (
index + 1 < command.length &&
command[index + 1] !== "\n" &&
command[index + 1] !== "\r"
) {
index += 1;
}
continue;
}
if (character === "<" || character === ">" || character === "(" || character === ")") {
unsupportedControl = true;
token += character;
continue;
}
if (/\s/u.test(character)) {
pushToken();
if (character === "\n" || character === "\r") pushSegment();
continue;
}
if (character === ";" || character === "|" || character === "&") {
pushSegment();
if (command[index + 1] === character) index += 1;
continue;
}
token += character;
}
if (quote || escaping) return null;
pushToken();
return Object.freeze({
segments: Object.freeze(
segments
.filter((segment) => segment.tokens.length > 0)
.map((segment) => Object.freeze({
tokens: Object.freeze(segment.tokens),
expansionTokens: Object.freeze(segment.expansionTokens),
})),
),
unsupportedControl,
});
}