diff --git a/.changeset/add-app-doctor-dependency-automation.md b/.changeset/add-app-doctor-dependency-automation.md new file mode 100644 index 00000000000..b8be89382f3 --- /dev/null +++ b/.changeset/add-app-doctor-dependency-automation.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Check for the presence of a local Dependabot or Renovate configuration file in App Doctor. diff --git a/packages/app/src/cli/services/app-doctor-engine/rules/catalog.ts b/packages/app/src/cli/services/app-doctor-engine/rules/catalog.ts index 1ff1fd4e694..42f5f181b91 100644 --- a/packages/app/src/cli/services/app-doctor-engine/rules/catalog.ts +++ b/packages/app/src/cli/services/app-doctor-engine/rules/catalog.ts @@ -145,6 +145,15 @@ export const RULE_CATALOG: RuleCatalogEntry[] = [ fix: 'Add the missing webhook subscriptions to shopify.app.toml.', guide: 'https://shopify.dev/docs/apps/webhooks/configuration/mandatory-webhooks', }, + { + id: 'MISSING_DEPENDENCY_SECURITY_AUTOMATION', + title: 'Repository-level dependency management configuration not detected', + severity: 'low', + points: -5, + description: + 'Looks for a local Dependabot or Renovate configuration file at the repository root without validating its contents.', + fix: 'Add a Dependabot or Renovate configuration file at the repository root, or verify existing coverage.', + }, { id: 'EOL_API_VERSION', title: 'End-of-life API version', diff --git a/packages/app/src/cli/services/app-doctor-engine/rules/dependency-automation-rules.ts b/packages/app/src/cli/services/app-doctor-engine/rules/dependency-automation-rules.ts new file mode 100644 index 00000000000..60ef90a0b75 --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/rules/dependency-automation-rules.ts @@ -0,0 +1,71 @@ +import type {Issue} from '../types.js' +import type {ManifestFile, ScanContext} from './types.js' + +export const DEPENDENCY_AUTOMATION_CONFIG_PATHS = [ + '.github/dependabot.yml', + '.github/dependabot.yaml', + 'renovate.json', + 'renovate.jsonc', + 'renovate.json5', + '.github/renovate.json', + '.github/renovate.jsonc', + '.github/renovate.json5', + '.gitlab/renovate.json', + '.gitlab/renovate.jsonc', + '.gitlab/renovate.json5', + '.renovaterc', + '.renovaterc.json', + '.renovaterc.jsonc', + '.renovaterc.json5', +] + +function hasDeclaredDependencies(manifest: ManifestFile): boolean { + return Object.keys(manifest.dependencies).length > 0 || Object.keys(manifest.devDependencies ?? {}).length > 0 +} + +function repositoryManifestPath(path: string): string { + return path.replace(/\\/g, '/') +} + +/** Dependabot and Renovate configuration is repository-level, so prefer the root manifest. */ +function dependencyAutomationFindingFile(manifests: ManifestFile[]): string { + const paths = manifests.map((manifest) => repositoryManifestPath(manifest.path)) + const root = paths.find((path) => path === 'package.json') + if (root) return root + + return [...paths].sort((left, right) => { + const depthDelta = left.split('/').length - right.split('/').length + return depthDelta === 0 ? left.localeCompare(right) : depthDelta + })[0]! +} + +/** File presence is an adoption signal, not proof of valid configuration, execution, or dependency coverage. */ +export function scanDependencyAutomation(context: Pick): { + issues: Issue[] + unresolvedReason?: string +} { + const manifests = context.manifests.filter(hasDeclaredDependencies) + if (manifests.length === 0) return {issues: []} + const {files, unresolvedReason} = context.dependencyAutomation + if (unresolvedReason) return {issues: [], unresolvedReason} + if (files.length > 0) return {issues: []} + + return { + issues: [ + { + id: 'MISSING_DEPENDENCY_SECURITY_AUTOMATION', + severity: 'low', + points: -5, + title: 'Repository-level dependency management configuration not detected', + message: + 'No recognized Dependabot or Renovate configuration file was found at the repository root. Add dependency update automation there, or verify that an existing integration covers this app. This check only looks for local configuration files; it does not validate their contents or inspect hosted integrations, CI workflows, or execution results.', + location: {file: dependencyAutomationFindingFile(manifests)}, + fix: { + automated: false, + description: + 'Add a Dependabot or Renovate configuration file at the repository root, or verify existing coverage.', + }, + }, + ], + } +} diff --git a/packages/app/src/cli/services/app-doctor-engine/rules/types.ts b/packages/app/src/cli/services/app-doctor-engine/rules/types.ts index bbd93d3f3af..783da46584d 100644 --- a/packages/app/src/cli/services/app-doctor-engine/rules/types.ts +++ b/packages/app/src/cli/services/app-doctor-engine/rules/types.ts @@ -1,5 +1,11 @@ import type {Issue, Capabilities, ProjectDetection, Severity, SourceCandidate} from '../types.js' -import type {AppTomlContent, ExtensionInfo, ManifestFile, SourceFile} from '../scanners/types.js' +import type { + AppTomlContent, + DependencyAutomationInputs, + ExtensionInfo, + ManifestFile, + SourceFile, +} from '../scanners/types.js' export type {AppTomlContent, ExtensionInfo, ManifestFile, SourceFile} from '../scanners/types.js' @@ -38,8 +44,10 @@ export interface ScanContext { extensions: ExtensionInfo[] /** Source files read for supported non-secret deterministic analysis. */ sourceFiles: SourceFile[] - /** Package manifest files found (package.json, Gemfile, composer.json) */ + /** Supported JavaScript package manifests found. */ manifests: ManifestFile[] + /** Local dependency-management configuration and discovery obstacles. */ + dependencyAutomation: DependencyAutomationInputs /** Safely readable repository text evidence available to secret scanning. */ sensitiveFiles: SourceFile[] /** Detected capabilities */ diff --git a/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts b/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts index e8676379371..0903037cfbe 100644 --- a/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts +++ b/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts @@ -1,14 +1,33 @@ +import {DEPENDENCY_AUTOMATION_CONFIG_PATHS} from '../rules/dependency-automation-rules.js' import {APP_CONFIG_FILE_GLOB, isValidFormatAppConfigurationFileName} from '../../../models/app/config-file-naming.js' import {AppAccessScopesSchema, AppAuthSchema} from '../../../models/extensions/specifications/app_config_app_access.js' import {WebhookSubscriptionSchema} from '../../../models/extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.js' import {removeTrailingSlash} from '../../../models/extensions/specifications/validation/common.js' import {fileExistsSync, fileSizeSync, globSync, readFileSync} from '@shopify/cli-kit/node/fs' -import {basename, cwd, dirname, extname, joinPath, relativePath, resolvePath} from '@shopify/cli-kit/node/path' +import { + basename, + cwd, + dirname, + extname, + isAbsolutePath, + isSubpath, + joinPath, + normalizePath as normalizeCliPath, + relativePath, + resolvePath, +} from '@shopify/cli-kit/node/path' import {zod} from '@shopify/cli-kit/node/schema' import {decodeToml} from '@shopify/cli-kit/node/toml/codec' -import {lstatSync} from 'node:fs' +import {lstatSync, realpathSync} from 'node:fs' import type {SourceCandidate} from '../types.js' -import type {AppTomlContent, ExtensionInfo, SourceFile, ManifestFile, WebhookSubscription} from './types.js' +import type { + AppTomlContent, + DependencyAutomationInputs, + ExtensionInfo, + SourceFile, + ManifestFile, + WebhookSubscription, +} from './types.js' /** Expected user error while locating a Shopify app root. */ export class AppRootDiscoveryError extends Error { @@ -412,13 +431,141 @@ function readBoundedFile(path: string): RepositoryReadResult { } } +function repositoryPathFailure(detail: string): RepositoryReadFailure { + return {ok: false, reason: 'unreadable', detail} +} + +type InspectedPath = {status: 'missing'} | {status: 'file'; path: string} | {status: 'unresolved'; reason: string} + +function isMissingFilesystemEntry(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR') +} + +function inspectErrorReason(target: string, error: unknown): string { + const code = error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : undefined + return code ? `Could not inspect ${target} (${code})` : `Could not inspect ${target}` +} + +function repositoryDisplayPath(appRoot: string, path: string): string { + const relative = normalizeCliPath(relativePath(appRoot, path)) + if ( + relative.length === 0 || + relative === '.' || + relative === '..' || + relative.startsWith('../') || + isAbsolutePath(relative) + ) { + return 'path' + } + return relative +} + +/** + * Resolve a repository path while preserving the difference between absence + * and an unsafe or unreadable entry. Existing files take a single realpath; + * prefix walking runs only after ENOENT/ENOTDIR so optional allowlist paths + * can distinguish ordinary absence from a dangling or escaping intermediate. + */ +function inspectRepositoryPath(appRoot: string, path: string): InspectedPath { + const absoluteRoot = resolvePath(appRoot) + const absolutePath = resolvePath(absoluteRoot, path) + const display = repositoryDisplayPath(absoluteRoot, absolutePath) + if (!isSubpath(absoluteRoot, absolutePath)) { + return {status: 'unresolved', reason: `${display} escapes the app root`} + } + + let canonicalRoot: string + try { + canonicalRoot = realpathSync(absoluteRoot) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return {status: 'unresolved', reason: inspectErrorReason('app root', error)} + } + + const segments = normalizeCliPath(relativePath(absoluteRoot, absolutePath)) + .split('/') + .filter((segment) => segment.length > 0 && segment !== '.') + if (segments.includes('..')) return {status: 'unresolved', reason: `${display} escapes the app root`} + if (segments.length === 0) return {status: 'unresolved', reason: `${display} is not a file`} + + try { + const canonicalPath = realpathSync(absolutePath) + if (!isSubpath(canonicalRoot, canonicalPath)) { + return {status: 'unresolved', reason: `${display} resolves outside the app root`} + } + if (!lstatSync(canonicalPath).isFile()) { + return {status: 'unresolved', reason: `${display} is not a file`} + } + return {status: 'file', path: canonicalPath} + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + if (isMissingFilesystemEntry(error)) { + return inspectMissingRepositoryPath(canonicalRoot, segments, display) + } + return {status: 'unresolved', reason: inspectErrorReason(display, error)} + } +} + +function inspectMissingRepositoryPath(canonicalRoot: string, segments: string[], display: string): InspectedPath { + let currentPath = canonicalRoot + for (const [index, segment] of segments.entries()) { + currentPath = joinPath(currentPath, segment) + try { + lstatSync(currentPath) + const canonicalPath = realpathSync(currentPath) + if (!isSubpath(canonicalRoot, canonicalPath)) { + return {status: 'unresolved', reason: `${display} resolves outside the app root`} + } + + const stats = lstatSync(canonicalPath) + const isLastSegment = index === segments.length - 1 + if (isLastSegment) { + if (!stats.isFile()) return {status: 'unresolved', reason: `${display} is not a file`} + } else if (!stats.isDirectory()) { + return {status: 'unresolved', reason: `${display} contains an entry that is not a directory`} + } + currentPath = canonicalPath + // Discovery must distinguish a missing optional allowlist entry from an + // entry that exists but cannot be inspected safely. + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + if (isMissingFilesystemEntry(error)) { + // lstat succeeds for a dangling link, so ENOENT from realpath is an + // unsafe existing entry rather than ordinary allowlist absence. + try { + if (lstatSync(currentPath).isSymbolicLink()) { + return {status: 'unresolved', reason: `${display} contains a dangling symbolic link`} + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return {status: 'missing'} + } + return {status: 'missing'} + } + return {status: 'unresolved', reason: inspectErrorReason(display, error)} + } + } + + return {status: 'file', path: currentPath} +} + +function containedRepositoryPath(appRoot: string, path: string): {path?: string; failure?: RepositoryReadFailure} { + const inspected = inspectRepositoryPath(appRoot, path) + if (inspected.status === 'file') return {path: inspected.path} + if (inspected.status === 'missing') return {failure: repositoryPathFailure('path does not exist')} + return {failure: repositoryPathFailure(inspected.reason)} +} + function readRepositoryFile(appRoot: string, path: string): RepositoryReadResult { + const absoluteRoot = resolvePath(appRoot) const absolutePath = resolvePath(path) - const cached = repositoryFileCache.get(absolutePath) + const cacheKey = `${absoluteRoot}\0${absolutePath}` + const cached = repositoryFileCache.get(cacheKey) if (cached) return cached - const result = readBoundedFile(absolutePath) - repositoryFileCache.set(absolutePath, result) + const containedPath = containedRepositoryPath(absoluteRoot, absolutePath) + const result = containedPath.path ? readBoundedFile(containedPath.path) : containedPath.failure! + repositoryFileCache.set(cacheKey, result) if (!result.ok) recordSkippedFile(appRoot, path, result) return result } @@ -599,6 +746,95 @@ export function findSensitiveFiles(appRoot: string): SourceFile[] { }) } +function nestedRepositoryReason(appRoot: string): string | undefined { + try { + const stats = lstatSync(joinPath(appRoot, '.git')) + if (stats.isDirectory() || stats.isFile()) return undefined + // A root marker establishes the app's repository boundary only when it is + // a directory or worktree file. Never follow ambiguous marker entries. + return 'Could not determine repository ownership from .git' + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + if (!isMissingFilesystemEntry(error)) return inspectErrorReason('.git', error) + } + + let ancestor = dirname(appRoot) + while (true) { + try { + const stats = lstatSync(joinPath(ancestor, '.git')) + if (stats.isDirectory() || stats.isFile()) { + return 'App root is nested below a parent Git repository' + } + // A symlink or special file is not a supported repository marker, but + // treating it as absent would make repository ownership ambiguous. + return 'Could not determine repository ownership from .git' + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + if (!isMissingFilesystemEntry(error)) return inspectErrorReason('.git', error) + } + + const parent = dirname(ancestor) + if (parent === ancestor) return undefined + ancestor = parent + } +} + +function recordRejectedAllowlistPath(appRoot: string, relative: string, failure: RepositoryReadFailure): void { + recordSkippedFile(appRoot, resolvePath(appRoot, relative), failure) +} + +/** Read local bot configuration only; hosted integrations and CI workflows are outside this check's scope. */ +export function findDependencyAutomationInputs(appRoot: string): DependencyAutomationInputs { + let canonicalRoot: string + try { + canonicalRoot = realpathSync(resolvePath(appRoot)) + if (!lstatSync(canonicalRoot).isDirectory()) { + return {files: [], unresolvedReason: 'App root is not a directory'} + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return {files: [], unresolvedReason: inspectErrorReason('app root', error)} + } + + const repositoryReason = nestedRepositoryReason(canonicalRoot) + if (repositoryReason) return {files: [], unresolvedReason: repositoryReason} + + const files: SourceFile[] = [] + let unresolvedReason: string | undefined + for (const relative of DEPENDENCY_AUTOMATION_CONFIG_PATHS) { + const inspected = inspectRepositoryPath(canonicalRoot, relative) + if (inspected.status === 'missing') continue + if (inspected.status === 'unresolved') { + recordRejectedAllowlistPath(canonicalRoot, relative, { + ok: false, + reason: 'unreadable', + detail: inspected.reason, + }) + unresolvedReason ??= inspected.reason + continue + } + + const absolutePath = resolvePath(canonicalRoot, relative) + const result = readBoundedFile(inspected.path) + if (!result.ok) { + recordRejectedAllowlistPath(canonicalRoot, relative, result) + unresolvedReason ??= + result.reason === 'too_large' ? `${relative} is too large to inspect` : `Could not read ${relative}` + continue + } + + files.push({ + path: relative, + absolutePath, + ext: extname(relative), + content: result.content.toString(), + }) + // One safely inspected configuration file is enough for this presence-only check. + break + } + return files.length > 0 ? {files} : {files, ...(unresolvedReason ? {unresolvedReason} : {})} +} + /** Find JavaScript package manifests. Dependency analysis intentionally supports JavaScript only. */ export function findManifestPaths(appRoot: string): string[] { const paths = globSync(['**/package.json'], { @@ -612,6 +848,11 @@ export function findManifestPaths(appRoot: string): string[] { return [...new Set(paths)].sort() } +const PackageManifestSchema = zod.object({ + dependencies: zod.record(zod.string()).optional(), + devDependencies: zod.record(zod.string()).optional(), +}) + export function findManifests(appRoot: string, discoveredPaths = findManifestPaths(appRoot)): ManifestFile[] { const manifests: ManifestFile[] = [] @@ -622,7 +863,7 @@ export function findManifests(appRoot: string, discoveredPaths = findManifestPat const content = readRepositoryText(appRoot, fullPath) if (content === undefined) continue try { - const pkg = JSON.parse(content) + const pkg = PackageManifestSchema.parse(JSON.parse(content)) manifests.push({ path: pkgPath, absolutePath: fullPath, diff --git a/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts b/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts index c6a8d0bc98b..275c4f1cfce 100644 --- a/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts +++ b/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts @@ -10,6 +10,7 @@ import { getSkippedFiles, findManifests, findManifestPaths, + findDependencyAutomationInputs, } from './discover.js' import {detectCapabilities, detectProject} from '../capabilities/detect.js' import {calculateScore, computeScanMetadata} from '../scorer/index.js' @@ -28,6 +29,7 @@ import {missingComplianceWebhooks, scanEolApiVersions} from '../rules/compliance import {scanAppProxyLiquidInjection} from '../rules/proxy-rules.js' import {scanExpiringOfflineTokens} from '../rules/token-rules.js' import {scanStaticFrameAncestors} from '../rules/csp-rules.js' +import {scanDependencyAutomation} from '../rules/dependency-automation-rules.js' import {RULE_CATALOG} from '../rules/catalog.js' import {redactIssue} from '../trace/index.js' import {getEngineVersion} from '../version.js' @@ -47,7 +49,15 @@ import type { SkippedFile, } from '../types.js' -type CheckTarget = 'config' | 'source' | 'app_source' | 'theme' | 'secrets' | 'config_and_source' | 'source_and_theme' +type CheckTarget = + | 'config' + | 'source' + | 'app_source' + | 'theme' + | 'secrets' + | 'config_and_source' + | 'source_and_theme' + | 'dependency_automation' interface RunnerImplementationResult { id: string analysisMode: AnalysisMode @@ -108,6 +118,16 @@ const jsCheck = ( /** The only active deterministic product checks. Shared agent IDs are deliberate fallback coverage. */ const DETERMINISTIC_CHECK_DEFINITIONS: ReadonlyArray = [ configRule(missingComplianceWebhooks), + { + id: 'MISSING_DEPENDENCY_SECURITY_AUTOMATION', + version: 1, + lifecycle: 'active', + analysisMode: 'structured_config', + target: 'dependency_automation', + guidance: + 'Resolve unreadable configuration files. Repository-level configuration is not inspected for nested apps; verify existing dependency automation manually.', + runner: (context) => scanDependencyAutomation(context), + }, { id: 'EOL_API_VERSION', version: 1, @@ -362,6 +382,11 @@ async function gitProject(appRoot: string): Promise { function selectedFiles(definition: DeterministicCheckDefinition, context: ScanContext): string[] { const configurations = context.appTomls.map((toml) => relativePath(context.appRoot, toml.path).replace(/\\/g, '/')) if (definition.target === 'config') return configurations + if (definition.target === 'dependency_automation') + return [ + ...context.manifests.map((manifest) => manifest.path), + ...context.dependencyAutomation.files.filter((file) => file.content !== undefined).map((file) => file.path), + ] if (definition.target === 'secrets') return context.sensitiveFiles.filter((file) => file.content !== undefined).map((file) => file.path) let files = definition.target === 'app_source' ? appSourceFiles(context) : reactRouterFiles(context) @@ -389,6 +414,13 @@ function executionDisposition( const reactRouterSupported = context.detection.framework === 'react_router' const hasTheme = context.capabilities.theme_app_extension + if (definition.target === 'dependency_automation' && !context.manifests.some(manifestHasDependencies)) + return { + status: 'not_applicable', + required: false, + applicable: false, + reason: {code: 'no_relevant_files', message: 'No supported package manifest declares dependencies.'}, + } if (definition.target === 'source' && !reactRouterSupported) { if (nonThemeCandidates.length > 0) return { @@ -497,6 +529,10 @@ function executionDisposition( return {status: 'executed', required: true, applicable: true} } +function manifestHasDependencies(manifest: ScanContext['manifests'][number]): boolean { + return Object.keys(manifest.dependencies).length > 0 || Object.keys(manifest.devDependencies ?? {}).length > 0 +} + function skippedInputsForCheck( definition: DeterministicCheckDefinition, context: ScanContext, @@ -510,6 +546,8 @@ function skippedInputsForCheck( const isSourcePath = (path: string) => !isThemePath(path) && Boolean(definition.extensions?.some((extension) => path.endsWith(extension))) const isConfig = (path: string) => /^shopify\.app(?:\.[^/]+)?\.toml$/.test(path) + const isDependencyAutomationInput = (path: string) => + /(^|\/)package\.json$/.test(path) || context.dependencyAutomation.files.some((file) => file.path === path) const isSecretInput = (file: SkippedFile) => !file.detail?.includes('could not be parsed') && (context.sourceCandidates.some((candidate) => candidate.path === file.path) || @@ -518,6 +556,7 @@ function skippedInputsForCheck( return skippedFiles.filter((file) => { if (definition.target === 'config') return isConfig(file.path) + if (definition.target === 'dependency_automation') return isDependencyAutomationInput(file.path) if (definition.target === 'config_and_source') return isConfig(file.path) || isSourcePath(file.path) if (definition.target === 'source' || definition.target === 'app_source') return isSourcePath(file.path) if (definition.target === 'theme') return isThemePath(file.path) @@ -562,6 +601,9 @@ export async function scan(startPath?: string): Promise { const sensitiveFiles = findSensitiveFiles(appRoot) const manifestPaths = findManifestPaths(appRoot) const manifests = findManifests(appRoot, manifestPaths) + const dependencyAutomation = manifests.some(manifestHasDependencies) + ? findDependencyAutomationInputs(appRoot) + : {files: []} const requestedAppToml = startPath?.endsWith('.toml') ? (appTomls.find((toml) => basename(toml.path) === basename(startPath)) ?? loadAppToml(joinPath(appRoot, basename(startPath)), appRoot)) @@ -583,6 +625,7 @@ export async function scan(startPath?: string): Promise { extensions, sourceFiles, manifests, + dependencyAutomation, sensitiveFiles, capabilities, detection, @@ -596,10 +639,16 @@ export async function scan(startPath?: string): Promise { let inspectedFiles = disposition.status === 'unsupported_framework' ? [] : selectedFiles(definition, context) let implementations: RunnerImplementationResult[] | undefined const before = issues.length - if ((disposition.status === 'executed' || disposition.status === 'unresolved') && definition.runner) { + const rejectedInputs = skippedInputsForCheck(definition, context, getSkippedFiles()) + const suppressRunnerForRejectedInput = definition.target === 'dependency_automation' && rejectedInputs.length > 0 + if ( + (disposition.status === 'executed' || disposition.status === 'unresolved') && + definition.runner && + !suppressRunnerForRejectedInput + ) { // eslint-disable-next-line no-await-in-loop const output = normalizeRunnerResult(await definition.runner(runnerContext(definition, context))) - issues.push(...output.issues) + if (definition.target !== 'dependency_automation' || !output.unresolvedReason) issues.push(...output.issues) implementations = output.implementations if (output.inspectedFiles) inspectedFiles = output.inspectedFiles if (output.unresolvedReason) @@ -613,7 +662,6 @@ export async function scan(startPath?: string): Promise { }, } } - const rejectedInputs = skippedInputsForCheck(definition, context, getSkippedFiles()) if (disposition.status !== 'unsupported_framework' && rejectedInputs.length > 0) { const reason = skippedInputReason(definition, rejectedInputs) disposition = {status: 'unresolved', required: true, applicable: true, reason} @@ -683,6 +731,7 @@ export async function scan(startPath?: string): Promise { ...appTomls.map((toml) => ({absolutePath: toml.path, content: toml.content})), ...extensions.map((extension) => ({absolutePath: joinPath(appRoot, extension.path), content: extension.content})), ...manifests.map((manifest) => ({absolutePath: manifest.absolutePath, content: manifest.content})), + ...dependencyAutomation.files.map((file) => ({absolutePath: joinPath(appRoot, file.path), content: file.content})), ] for (const {absolutePath, content} of configFiles) if (content !== undefined) { diff --git a/packages/app/src/cli/services/app-doctor-engine/scanners/types.ts b/packages/app/src/cli/services/app-doctor-engine/scanners/types.ts index eeb7bfbb999..eb880447844 100644 --- a/packages/app/src/cli/services/app-doctor-engine/scanners/types.ts +++ b/packages/app/src/cli/services/app-doctor-engine/scanners/types.ts @@ -42,6 +42,13 @@ export interface SourceFile { content?: string } +/** Explicitly allowlisted dependency-management configuration inside the app-root evidence boundary. */ +export interface DependencyAutomationInputs { + files: SourceFile[] + /** A specific discovery obstacle, including an app nested below its repository root. */ + unresolvedReason?: string +} + export interface ManifestFile { path: string absolutePath: string diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation-discovery.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation-discovery.test.ts new file mode 100644 index 00000000000..6bf63cf05e3 --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation-discovery.test.ts @@ -0,0 +1,198 @@ +/* eslint-disable no-restricted-imports -- discovery boundaries use real temporary repositories */ +import { + findDependencyAutomationInputs, + findManifests, + getSkippedFiles, + resetSkippedFiles, +} from '../scanners/discover.js' +import {DEPENDENCY_AUTOMATION_CONFIG_PATHS} from '../rules/dependency-automation-rules.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {afterEach, describe, expect, test} from 'vitest' +import {execFileSync} from 'node:child_process' +import {mkdir, symlink, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' + +afterEach(() => resetSkippedFiles()) + +async function writeFiles(root: string, files: Record): Promise { + await Promise.all( + Object.entries(files).map(async ([path, content]) => { + await mkdir(dirname(join(root, path)), {recursive: true}) + await writeFile(join(root, path), content) + }), + ) +} + +describe('dependency automation discovery', () => { + test('reads only allowlisted config, preserving exact bytes and ignoring workflows', async () => { + await inTemporaryDirectory(async (root) => { + expect(findDependencyAutomationInputs(root)).toEqual({files: []}) + const content = 'version: 2\r\nupdates: []\r\n' + await writeFiles(root, { + '.github/dependabot.yml': content, + '.gitlab/renovate.json': '{}', + '.github/workflows/audit.yml': 'jobs: [', + '.gitlab-ci.yml': 'include: [', + '.circleci/config.yml': 'jobs: [', + '.snyk': 'version: v1.25.0', + }) + const result = findDependencyAutomationInputs(root) + expect(result.unresolvedReason).toBeUndefined() + expect(result.files.map(({path}) => path)).toEqual(['.github/dependabot.yml']) + expect(result.files[0]?.content).toBe(content) + }) + }) + + test.each(DEPENDENCY_AUTOMATION_CONFIG_PATHS)('discovers the allowlisted file %s', async (path) => { + await inTemporaryDirectory(async (root) => { + await writeFiles(root, {[path]: '{}'}) + const result = findDependencyAutomationInputs(root) + expect(result.files).toMatchObject([{path, content: '{}'}]) + expect(result.unresolvedReason).toBeUndefined() + }) + }) + + test('stops discovery after finding one configuration file', async () => { + await inTemporaryDirectory(async (root) => { + await writeFiles(root, {'renovate.json': '{}', '.renovaterc': 'x'.repeat(500_001)}) + expect(findDependencyAutomationInputs(root).files).toMatchObject([{path: 'renovate.json'}]) + expect(getSkippedFiles()).toEqual([]) + }) + }) + + test('preserves bounded reads and skipped-file coverage', async () => { + await inTemporaryDirectory(async (root) => { + await writeFiles(root, {'.github/dependabot.yml': 'x'.repeat(500_001)}) + expect(findDependencyAutomationInputs(root)).toMatchObject({ + files: [], + unresolvedReason: expect.stringContaining('too large'), + }) + expect(getSkippedFiles()).toEqual([ + expect.objectContaining({path: '.github/dependabot.yml', reason: 'too_large', size_bytes: 500_001}), + ]) + }) + }) + + test.each(['directory', 'worktree file'])('respects repository %s boundaries', async (marker) => { + await inTemporaryDirectory(async (repository) => { + const app = join(repository, 'apps', 'example') + await writeFiles(app, {'.github/dependabot.yml': 'version: 2\nupdates: []'}) + if (marker === 'directory') await mkdir(join(repository, '.git')) + else await writeFile(join(repository, '.git'), 'gitdir: /outside/not-read') + const nested = findDependencyAutomationInputs(app) + expect(nested).toMatchObject({ + files: [], + unresolvedReason: 'App root is nested below a parent Git repository', + }) + expect(nested.unresolvedReason).not.toContain(repository) + if (marker === 'directory') await mkdir(join(app, '.git')) + else await writeFile(join(app, '.git'), 'gitdir: /outside/not-read') + expect(findDependencyAutomationInputs(app).files).toMatchObject([{path: '.github/dependabot.yml'}]) + }) + }) + + test.each(['.github', '.gitlab', '.git'])('rejects escaping or ambiguous %s directory links', async (path) => { + await inTemporaryDirectory(async (root) => { + await inTemporaryDirectory(async (outside) => { + await symlink(outside, join(root, path), 'dir') + const result = findDependencyAutomationInputs(root) + expect(result).toMatchObject({ + files: [], + unresolvedReason: expect.any(String), + }) + expect(result.unresolvedReason).not.toContain(root) + expect(result.unresolvedReason).not.toContain(outside) + }) + }) + }) + + test('rejects dangling directory links', async () => { + await inTemporaryDirectory(async (root) => { + await symlink(join(root, 'missing'), join(root, '.github'), 'dir') + const result = findDependencyAutomationInputs(root) + expect(result.unresolvedReason).toContain('dangling symbolic link') + expect(result.unresolvedReason).not.toContain(root) + expect(getSkippedFiles()).toContainEqual( + expect.objectContaining({path: '.github/dependabot.yml', reason: 'unreadable'}), + ) + }) + }) + + test('keeps looking after an unsafe allowlisted path', async () => { + await inTemporaryDirectory(async (root) => { + await inTemporaryDirectory(async (outside) => { + await symlink(outside, join(root, '.github'), 'dir') + await writeFiles(root, {'renovate.json': '{}'}) + const result = findDependencyAutomationInputs(root) + expect(result.files).toMatchObject([{path: 'renovate.json', content: '{}'}]) + expect(result.unresolvedReason).toBeUndefined() + expect(getSkippedFiles()).toContainEqual( + expect.objectContaining({path: '.github/dependabot.yml', reason: 'unreadable'}), + ) + }) + }) + }) + + test('rejects escaping config-file links and accepts contained ones', async () => { + await inTemporaryDirectory(async (root) => { + await inTemporaryDirectory(async (outside) => { + await writeFile(join(outside, 'config.json'), '{}') + await symlink(join(outside, 'config.json'), join(root, 'renovate.json')) + const rejected = findDependencyAutomationInputs(root) + expect(rejected.unresolvedReason).toContain('outside the app root') + expect(rejected.unresolvedReason).not.toContain(root) + expect(rejected.unresolvedReason).not.toContain(outside) + expect(getSkippedFiles()).toContainEqual(expect.objectContaining({path: 'renovate.json', reason: 'unreadable'})) + await writeFiles(root, {'.github/config.yml': 'version: 2\nupdates: []'}) + await symlink(join(root, '.github/config.yml'), join(root, '.github/dependabot.yml')) + const result = findDependencyAutomationInputs(root) + expect(result.files).toMatchObject([{path: '.github/dependabot.yml'}]) + expect(result.unresolvedReason).toBeUndefined() + }) + }) + }) + + test.skipIf(process.platform === 'win32')('rejects special files without attempting to read them', async () => { + await inTemporaryDirectory(async (root) => { + execFileSync('mkfifo', [join(root, 'renovate.json')]) + expect(findDependencyAutomationInputs(root).unresolvedReason).toContain('not a file') + execFileSync('mkfifo', [join(root, '.git')]) + expect(findDependencyAutomationInputs(root).unresolvedReason).toContain('repository ownership') + }) + }) +}) + +describe('manifest safety', () => { + test('rejects escaped manifests and parent paths', async () => { + await inTemporaryDirectory(async (root) => { + await inTemporaryDirectory(async (outside) => { + await writeFile(join(outside, 'package.json'), '{"dependencies":{"react":"19.0.0"}}') + await symlink(join(outside, 'package.json'), join(root, 'package.json')) + expect(findManifests(root, ['package.json', '../package.json'])).toEqual([]) + expect(getSkippedFiles()).toHaveLength(2) + }) + }) + }) + + test.each(['null', '[]', '{"dependencies":{"react":1}}', '{"devDependencies":{"vitest":null}}'])( + 'records malformed manifests as coverage gaps: %s', + async (content) => { + await inTemporaryDirectory(async (root) => { + await writeFile(join(root, 'package.json'), content) + expect(findManifests(root)).toMatchObject([{dependencies: {}, devDependencies: {}}]) + expect(getSkippedFiles()).toEqual([ + expect.objectContaining({reason: 'unreadable', detail: 'manifest could not be parsed'}), + ]) + }) + }, + ) + + test('does not retain or validate package scripts for this check', async () => { + await inTemporaryDirectory(async (root) => { + await writeFile(join(root, 'package.json'), '{"scripts":{"audit":["npm audit"]}}') + expect(findManifests(root)).toMatchObject([{dependencies: {}, devDependencies: {}}]) + expect(findManifests(root)[0]).not.toHaveProperty('scripts') + expect(getSkippedFiles()).toEqual([]) + }) + }) +}) diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation-rules.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation-rules.test.ts new file mode 100644 index 00000000000..ed1cfe2e9e7 --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation-rules.test.ts @@ -0,0 +1,74 @@ +import {scanDependencyAutomation} from '../rules/dependency-automation-rules.js' +import {describe, expect, test} from 'vitest' +import type {ManifestFile, ScanContext} from '../rules/types.js' + +const manifest = (path = 'package.json'): ManifestFile => ({ + path, + absolutePath: `/${path}`, + type: 'npm', + dependencies: {react: '19.0.0'}, +}) + +function scan(dependencyAutomation: ScanContext['dependencyAutomation'] = {files: []}, manifests = [manifest()]) { + return scanDependencyAutomation({manifests, dependencyAutomation}) +} + +describe('dependency-management configuration file presence', () => { + test('is not applicable without declared dependencies', () => { + expect(scan({files: []}, [])).toEqual({issues: []}) + expect(scan({files: []}, [{...manifest(), dependencies: {}}])).toEqual({issues: []}) + }) + + test('includes development dependencies and anchors one finding to a real manifest', () => { + const result = scan({files: []}, [manifest('packages/web/package.json'), manifest()]) + expect( + scan({files: []}, [{...manifest(), dependencies: {}, devDependencies: {vitest: '4.0.0'}}]).issues, + ).toHaveLength(1) + expect(result.issues).toEqual([ + expect.objectContaining({ + id: 'MISSING_DEPENDENCY_SECURITY_AUTOMATION', + severity: 'low', + points: -5, + title: 'Repository-level dependency management configuration not detected', + location: {file: 'package.json'}, + fix: expect.objectContaining({automated: false}), + }), + ]) + expect(result.issues[0]?.message).toContain('repository root') + expect(result.issues[0]?.message).toContain('does not validate their contents') + }) + + test('prefers the root manifest over a nested extension even when localeCompare would not', () => { + const result = scan({files: []}, [ + manifest('extensions/app-home/package.json'), + manifest('web/package.json'), + manifest(), + ]) + expect(result.issues[0]?.location).toEqual({file: 'package.json'}) + }) + + test('falls back to the shallowest nested manifest when the root has no dependencies', () => { + const result = scan({files: []}, [ + {...manifest(), dependencies: {}}, + manifest('extensions/app-home/package.json'), + manifest('web/package.json'), + ]) + expect(result.issues[0]?.location).toEqual({file: 'web/package.json'}) + }) + + test('preserves discovery obstacles without a finding', () => { + expect(scan({files: [], unresolvedReason: 'Nested repository'})).toEqual({ + issues: [], + unresolvedReason: 'Nested repository', + }) + }) + + test.each(['', '{malformed', '{"enabled":false}', '{"extends":["local>org/config"]}'])( + 'treats presence as sufficient, regardless of configuration content: %s', + (content) => { + expect(scan({files: [{path: 'renovate.json', absolutePath: '/renovate.json', ext: '.json', content}]})).toEqual({ + issues: [], + }) + }, + ) +}) diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation.test.ts new file mode 100644 index 00000000000..9af1a07a0cc --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-automation.test.ts @@ -0,0 +1,215 @@ +/* eslint-disable no-restricted-imports -- integration coverage uses real temporary repositories */ +import {doctorExitCode} from '../../app-doctor-api.js' +import {formatJson} from '../output/format.js' +import {getRegistry} from '../registry/index.js' +import {DETERMINISTIC_CHECKS, scan} from '../scanners/index.js' +import {buildSubmission} from '../submission/index.js' +import {sha256, validateTrace} from '../trace/index.js' +import {scanApp} from '../run.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {fetch} from '@shopify/cli-kit/node/http' +import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system' +import {describe, expect, test, vi} from 'vitest' +import {mkdir, unlink, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' + +vi.mock('@shopify/cli-kit/node/http', async (importActual) => { + const actual: any = await importActual() + return {...actual, fetch: vi.fn(actual.fetch)} +}) +vi.mock('@shopify/cli-kit/node/system', async (importActual) => { + const actual: any = await importActual() + return {...actual, captureOutputWithExitCode: vi.fn(actual.captureOutputWithExitCode)} +}) + +const checkId = 'MISSING_DEPENDENCY_SECURITY_AUTOMATION' +const dependabot = '# Configuration contents are not validated.\n' + +async function writeFiles(root: string, files: Record): Promise { + await Promise.all( + Object.entries(files).map(async ([path, content]) => { + await mkdir(dirname(join(root, path)), {recursive: true}) + await writeFile(join(root, path), content) + }), + ) +} + +async function makeApp(root: string, files: Record = {}): Promise { + await writeFiles(root, { + 'shopify.app.toml': `name = "Dependency automation integration" +[webhooks] +api_version = "unstable" +[[webhooks.subscriptions]] +compliance_topics = ["shop/redact", "customers/data_request", "customers/redact"] +uri = "https://example.test/webhooks" +`, + 'package.json': JSON.stringify({dependencies: {react: '^19.0.0'}}), + ...files, + }) +} + +function dependencyExecution(result: Awaited>) { + return result.scan.checks_executed.find((execution) => execution.id === checkId)! +} + +function dependencyFindings(result: Awaited>) { + return result.issues.filter((issue) => issue.id === checkId) +} + +describe('dependency automation scanner integration', () => { + test('registers one framework-independent low-severity structured-config check', () => { + expect(DETERMINISTIC_CHECKS.get(checkId)).toMatchObject({ + version: 1, + lifecycle: 'active', + analysisMode: 'structured_config', + target: 'dependency_automation', + }) + expect(DETERMINISTIC_CHECKS.get(checkId)).not.toHaveProperty('requires') + expect(getRegistry().filter((entry) => entry.id === checkId)).toEqual([ + expect.objectContaining({kind: 'deterministic', status: 'active', severity: 'low', points: -5}), + ]) + }) + + test('does not inspect bot configuration when no supported dependencies apply', async () => { + await inTemporaryDirectory(async (root) => { + await makeApp(root, {'package.json': '{}', '.github/dependabot.yml': 'x'.repeat(500_001)}) + const result = await scan(root) + expect(dependencyFindings(result)).toEqual([]) + expect(dependencyExecution(result)).toMatchObject({status: 'not_applicable', findings: 0, applicable: false}) + }) + }) + + test('reports one ordinary finding through scoring, blocking, trace, and submission', async () => { + await inTemporaryDirectory(async (root) => { + await makeApp(root, { + 'extensions/app-home/package.json': JSON.stringify({dependencies: {react: '^19.0.0'}}), + }) + const execution = await scanApp(root) + expect(dependencyFindings(execution.scan)).toEqual([ + expect.objectContaining({id: checkId, severity: 'low', points: -5, location: {file: 'package.json'}}), + ]) + expect(dependencyExecution(execution.scan)).toMatchObject({ + status: 'executed', + required: true, + findings: 1, + inspected_files: ['extensions/app-home/package.json', 'package.json'], + }) + expect(execution.scan.score).toEqual({total: 95, baseline: 100, grade: 'EXCELLENT'}) + expect(formatJson(execution.scan)).toContain(checkId) + expect(validateTrace(execution.trace)).toEqual({valid: true, errors: []}) + const submission = buildSubmission(execution.trace, {cliVersion: '3.99.0', submittedAt: '2026-09-15T00:00:00Z'}) + for (const findings of [execution.trace.findings, submission.report.findings]) { + expect(findings).toContainEqual(expect.objectContaining({rule_id: checkId, severity: 'low'})) + } + expect(doctorExitCode({...execution, elapsedMilliseconds: 0}, 'low')).toBe(1) + expect(doctorExitCode({...execution, elapsedMilliseconds: 0}, 'medium')).toBe(0) + expect(doctorExitCode({...execution, elapsedMilliseconds: 0}, 'none')).toBe(0) + }) + }) + + test.each([ + ['.github/dependabot.yml', dependabot], + ['.gitlab/renovate.json', '{"extends":["local>org/renovate-config"]}'], + ['renovate.json', '{"enabled":false}'], + ['.github/dependabot.yaml', 'not valid YAML: ['], + ['.renovaterc', ''], + ])( + 'recognizes %s without network requests, repository commands, or configuration disclosure', + async (path, content) => { + await inTemporaryDirectory(async (root) => { + await makeApp(root, {[path]: content}) + const execution = await scanApp(root) + const expectedFiles = ['package.json', path] + expect(dependencyFindings(execution.scan)).toEqual([]) + expect(dependencyExecution(execution.scan)).toMatchObject({ + status: 'executed', + findings: 0, + inspected_files: expectedFiles, + }) + expect(execution.scan.score).toEqual({total: 100, baseline: 100, grade: 'EXCELLENT'}) + expect(execution.trace.project.input_hashes[path]).toBe(sha256(content)) + expect(doctorExitCode({...execution, elapsedMilliseconds: 0}, 'low')).toBe(0) + const submission = buildSubmission(execution.trace, {cliVersion: '3.99.0', submittedAt: '2026-09-15T00:00:00Z'}) + expect(JSON.stringify(submission)).not.toContain('local>org/renovate-config') + expect(JSON.stringify(execution.trace)).not.toContain('local>org/renovate-config') + expect(vi.mocked(captureOutputWithExitCode).mock.calls.map(([command, args]) => [command, args])).toEqual([ + ['git', ['rev-parse', 'HEAD']], + ['git', ['status', '--porcelain']], + ]) + expect(fetch).not.toHaveBeenCalled() + }) + }, + ) + + test('ignores workflow contents entirely, including malformed CI configuration', async () => { + await inTemporaryDirectory(async (root) => { + await makeApp(root) + const initial = await scan(root) + await writeFiles(root, { + '.github/workflows/audit.yml': 'jobs: [', + '.gitlab-ci.yml': 'include: [', + '.circleci/config.yml': 'jobs: [', + '.snyk': 'version: v1.25.0', + }) + const result = await scan(root) + expect(dependencyFindings(result)).toHaveLength(1) + expect(dependencyExecution(result)).toMatchObject({status: 'executed', inspected_files: ['package.json']}) + expect(result.scan.input_hash).toBe(initial.scan.input_hash) + }) + }) + + test('does not treat package.json as a Renovate configuration filename', async () => { + await inTemporaryDirectory(async (root) => { + await makeApp(root, {'package.json': '{"dependencies":{"react":"19.0.0"},"renovate":{}}'}) + expect(dependencyFindings(await scan(root))).toHaveLength(1) + }) + }) + + test.each([ + ['.github/dependabot.yml', 'x'.repeat(500_001)], + ['packages/web/package.json', '{invalid'], + ])('leaves malformed or rejected input unresolved: %s', async (path, content) => { + await inTemporaryDirectory(async (root) => { + await makeApp(root, {[path]: content}) + const result = await scan(root) + expect(dependencyFindings(result)).toEqual([]) + expect(dependencyExecution(result)).toMatchObject({status: 'unresolved', findings: 0}) + expect(result.score).toBeNull() + }) + }) + + test('does not inspect repository-level configuration outside a nested app root', async () => { + await inTemporaryDirectory(async (repository) => { + const app = join(repository, 'apps/example') + await mkdir(join(repository, '.git')) + await makeApp(app, {'.github/dependabot.yml': dependabot}) + const result = await scan(app) + expect(dependencyFindings(result)).toEqual([]) + expect(dependencyExecution(result)).toMatchObject({ + status: 'unresolved', + reason: {message: 'App root is nested below a parent Git repository'}, + }) + }) + }) + + test.each(['.github/dependabot.yml', 'renovate.json'])( + 'hashes config changes, additions, and deletions: %s', + async (path) => { + await inTemporaryDirectory(async (root) => { + await makeApp(root) + const initial = await scan(root) + const content = path.endsWith('.yml') ? dependabot : '{}' + await writeFiles(root, {[path]: content}) + const added = await scan(root) + await writeFile(join(root, path), `${content}\r\n`) + const changed = await scan(root) + await unlink(join(root, path)) + const deleted = await scan(root) + expect(added.scan.file_hashes?.[path]).toBe(sha256(content)) + expect(changed.scan.file_hashes?.[path]).toBe(sha256(`${content}\r\n`)) + expect(new Set([initial.scan.input_hash, added.scan.input_hash, changed.scan.input_hash]).size).toBe(3) + expect(deleted.scan.input_hash).toBe(initial.scan.input_hash) + }) + }, + ) +}) diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/deterministic-rules.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/deterministic-rules.test.ts index 0b5cadb715d..d8c050054e1 100644 --- a/packages/app/src/cli/services/app-doctor-engine/tests/deterministic-rules.test.ts +++ b/packages/app/src/cli/services/app-doctor-engine/tests/deterministic-rules.test.ts @@ -16,6 +16,7 @@ import type {SourceFile} from '../rules/types.js' const ACTIVE_IDS = [ 'MISSING_COMPLIANCE_WEBHOOKS', + 'MISSING_DEPENDENCY_SECURITY_AUTOMATION', 'EOL_API_VERSION', 'EXPIRING_OFFLINE_TOKEN', 'UNAUTHENTICATED_ENDPOINT', @@ -39,7 +40,7 @@ const source = (content: string, path = 'app/routes/example.tsx'): SourceFile => }) describe('deterministic rules product contract', () => { - test('has exactly fourteen active executable deterministic identities', () => { + test('has exactly fifteen active executable deterministic identities', () => { expect([...DETERMINISTIC_CHECKS.keys()].sort()).toEqual(ACTIVE_IDS) expect([...DETERMINISTIC_CHECKS.values()].every((check) => check.lifecycle === 'active' && check.runner)).toBe(true) const registry = getRegistry() diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/rule-analysis.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/rule-analysis.test.ts index aca081967be..6fddcb696bd 100644 --- a/packages/app/src/cli/services/app-doctor-engine/tests/rule-analysis.test.ts +++ b/packages/app/src/cli/services/app-doctor-engine/tests/rule-analysis.test.ts @@ -35,6 +35,7 @@ function context( extensions: [], sourceFiles: input.files ?? [], manifests: [], + dependencyAutomation: {files: []}, sensitiveFiles: [], capabilities: { theme_app_extension: false,