diff --git a/bin/deps-check.js b/bin/deps-check.js new file mode 100644 index 0000000..2f5246e --- /dev/null +++ b/bin/deps-check.js @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import SimpleCliCommand from '../lib/SimpleCliCommand.js' +import Utils from '../lib/Utils.js' +import { PREFIX, deriveExpectedPeerDeps, deriveExpectedDeps } from '../lib/peerDeps.js' + +const CORE_PKG = 'adapt-authoring-core' + +export default class DepsCheck extends SimpleCliCommand { + get config () { + return { + ...super.config, + description: 'Checks dependencies and peerDependencies against source code analysis', + params: {}, + options: [['--recursive', 'Check all AAT modules in child directories']], + getReleaseData: false + } + } + + async runTask () { + const cwd = process.cwd() + let moduleDirs + + if (this.options.recursive) { + moduleDirs = Utils.getModuleDirs(cwd) + if (moduleDirs.length === 0) { + console.log('No modules found in child directories.') + process.exitCode = 1 + return + } + } else { + if (!Utils.isModule(cwd)) { + console.error(`Not a valid module directory (no adapt-authoring.json found in ${cwd})`) + process.exitCode = 1 + return + } + moduleDirs = [cwd] + } + + const pkgIndex = Utils.buildPackageIndex(join(cwd, this.options.recursive ? '.' : '..')) + let totalErrors = 0 + + for (const moduleDir of moduleDirs) { + const errors = this.checkModule(moduleDir, pkgIndex) + totalErrors += errors + } + + if (totalErrors > 0) { + process.exitCode = 1 + } + } + + checkModule (moduleDir, pkgIndex) { + const pkgPath = join(moduleDir, 'package.json') + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const moduleName = pkg.name + const declaredPeerDeps = new Set(Object.keys(pkg.peerDependencies || {})) + const declaredDeps = new Set(Object.keys(pkg.dependencies || {}).filter(n => n.startsWith(PREFIX))) + + console.log(`Checking ${moduleName}...`) + + let peerResult = deriveExpectedPeerDeps(moduleDir, pkgIndex) + + const directDeps = pkg.dependencies || {} + const isCorePkg = moduleName === CORE_PKG + const hasCoreAsDirect = Object.hasOwn(directDeps, CORE_PKG) + + if (!isCorePkg && !hasCoreAsDirect) { + if (!peerResult) { + peerResult = { peerDeps: {} } + } + if (!peerResult.peerDeps[CORE_PKG]) { + const coreInfo = pkgIndex.get(CORE_PKG) + peerResult.peerDeps[CORE_PKG] = coreInfo ? `^${coreInfo.version}` : '*' + } + } + + const expectedPeerNames = peerResult ? new Set(Object.keys(peerResult.peerDeps)) : new Set() + const missingPeer = [...expectedPeerNames].filter(n => !declaredPeerDeps.has(n)).sort() + const extraPeer = [...declaredPeerDeps].filter(n => !expectedPeerNames.has(n)).sort() + + const depsResult = deriveExpectedDeps(moduleDir, pkgIndex) + const expectedDepNames = depsResult ? new Set(Object.keys(depsResult.expectedDeps)) : new Set() + const missingDeps = [...expectedDepNames].filter(n => !declaredDeps.has(n)).sort() + const extraDeps = [...declaredDeps].filter(n => !expectedDepNames.has(n)).sort() + + const errors = missingPeer.length + extraPeer.length + missingDeps.length + extraDeps.length + + if (errors === 0) { + console.log(`Checking ${moduleName}... ✓`) + return 0 + } + + console.log() + + if (missingDeps.length > 0) { + console.log('✗ Missing dependencies (imported in code but not in dependencies):') + for (const name of missingDeps) { + console.log(` - ${name}`) + } + console.log() + } + + if (extraDeps.length > 0) { + console.log('✗ Extra dependencies (in dependencies but not imported in code):') + for (const name of extraDeps) { + console.log(` - ${name}`) + } + console.log() + } + + if (missingPeer.length > 0) { + console.log('✗ Missing peerDependencies:') + for (const name of missingPeer) { + console.log(` - ${name}`) + } + console.log() + } + + if (extraPeer.length > 0) { + console.log('✗ Extra peerDependencies (not used in code):') + for (const name of extraPeer) { + console.log(` - ${name}`) + } + console.log() + } + + console.log(`Found ${errors} error(s).`) + return errors + } +} diff --git a/bin/deps-gen.js b/bin/deps-gen.js new file mode 100644 index 0000000..0de2f5b --- /dev/null +++ b/bin/deps-gen.js @@ -0,0 +1,201 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import SimpleCliCommand from '../lib/SimpleCliCommand.js' +import Utils from '../lib/Utils.js' +import { PREFIX, deriveExpectedPeerDeps, deriveExpectedDeps } from '../lib/peerDeps.js' + +const CORE_PKG = 'adapt-authoring-core' + +export default class DepsGen extends SimpleCliCommand { + get config () { + return { + ...super.config, + description: 'Generates correct dependencies and peerDependencies from source code analysis', + params: {}, + options: [ + ['--recursive', 'Process all AAT modules in child directories'], + ['--write', 'Write changes to package.json files'] + ], + getReleaseData: false + } + } + + async runTask () { + const cwd = process.cwd() + let moduleDirs + + if (this.options.recursive) { + moduleDirs = Utils.getModuleDirs(cwd) + if (moduleDirs.length === 0) { + console.log('No modules found in child directories.') + process.exitCode = 1 + return + } + } else { + if (!Utils.isModule(cwd)) { + console.error(`Not a valid module directory (no adapt-authoring.json found in ${cwd})`) + process.exitCode = 1 + return + } + moduleDirs = [cwd] + } + + const pkgIndex = Utils.buildPackageIndex(join(cwd, this.options.recursive ? '.' : '..')) + const updatedDirs = [] + let count = 0 + + for (const moduleDir of moduleDirs) { + const result = this.processModule(moduleDir, pkgIndex) + if (result) { + count++ + if (this.options.write) { + updatedDirs.push(moduleDir) + } + } + } + + if (count === 0) { + console.log('No modules with adapt-authoring dependencies found.') + } else if (!this.options.write) { + console.log(`\n${count} module(s) found. Run with --write to update package.json files.`) + } + + if (updatedDirs.length > 0) { + for (const dir of updatedDirs) { + console.log(` Running npm update in ${dir}...`) + try { + await Utils.exec('npm update', dir) + console.log(' ✓ npm update complete') + } catch (e) { + console.error(` ✗ npm update failed: ${e.message}`) + } + } + } + } + + processModule (moduleDir, pkgIndex) { + let peerResult = deriveExpectedPeerDeps(moduleDir, pkgIndex) + const depsResult = deriveExpectedDeps(moduleDir, pkgIndex) + + // Ensure adapt-authoring-core is a peerDependency for all modules + // that don't have it as a direct dependency + if (!peerResult) { + const modPkgPath = join(moduleDir, 'package.json') + if (existsSync(modPkgPath)) { + const modPkg = JSON.parse(readFileSync(modPkgPath, 'utf8')) + const directDeps = modPkg.dependencies || {} + if (modPkg.name !== CORE_PKG && !Object.hasOwn(directDeps, CORE_PKG)) { + peerResult = { moduleName: modPkg.name, pkgPath: modPkgPath, pkg: modPkg, peerDeps: {}, warnings: [] } + } + } + } + if (peerResult && peerResult.moduleName !== CORE_PKG) { + const directDeps = peerResult.pkg.dependencies || {} + if (!Object.hasOwn(directDeps, CORE_PKG) && !peerResult.peerDeps[CORE_PKG]) { + const coreInfo = pkgIndex.get(CORE_PKG) + peerResult.peerDeps[CORE_PKG] = coreInfo ? `^${coreInfo.version}` : '*' + const sorted = {} + for (const key of Object.keys(peerResult.peerDeps).sort()) { + sorted[key] = peerResult.peerDeps[key] + } + peerResult.peerDeps = sorted + } + } + + if (!peerResult && !depsResult) { + if (!this.options.recursive) console.log(`No adapt-authoring dependencies found in ${moduleDir}`) + return false + } + + const pkg = peerResult?.pkg || depsResult.pkg + const pkgPath = peerResult?.pkgPath || depsResult.pkgPath + const moduleName = peerResult?.moduleName || depsResult.moduleName + const peerDeps = peerResult?.peerDeps || {} + const expectedDeps = depsResult?.expectedDeps || {} + const warnings = [...(peerResult?.warnings || []), ...(depsResult?.warnings || [])] + + console.log(`\n${moduleName}:`) + + if (Object.keys(expectedDeps).length > 0) { + console.log(' dependencies:') + for (const [dep, ver] of Object.entries(expectedDeps)) { + console.log(` ${dep}: ${ver}`) + } + } + + if (Object.keys(peerDeps).length > 0) { + console.log(' peerDependencies:') + for (const [dep, ver] of Object.entries(peerDeps)) { + console.log(` ${dep}: ${ver}`) + } + } + + for (const w of warnings) { + console.log(` ⚠ ${w}`) + } + + if (this.options.write) { + this.writePackageJson(pkg, pkgPath, expectedDeps, peerDeps) + } + + return true + } + + writePackageJson (pkg, pkgPath, expectedDeps, peerDeps) { + // Update adapt-authoring-* dependencies: preserve non-adapt deps, add expected ones + const currentDeps = pkg.dependencies || {} + const updatedDeps = {} + for (const [dep, ver] of Object.entries(currentDeps)) { + if (!dep.startsWith(PREFIX)) { + updatedDeps[dep] = ver + } + } + for (const [dep, ver] of Object.entries(expectedDeps)) { + updatedDeps[dep] = ver + } + const sortedDeps = {} + for (const key of Object.keys(updatedDeps).sort()) { + sortedDeps[key] = updatedDeps[key] + } + pkg.dependencies = sortedDeps + + pkg.peerDependencies = peerDeps + + const peerDepsMeta = {} + for (const dep of Object.keys(peerDeps)) { + peerDepsMeta[dep] = { optional: true } + } + pkg.peerDependenciesMeta = peerDepsMeta + + // Rebuild the package object with correct key ordering: + // peerDependencies and peerDependenciesMeta directly after dependencies + const ordered = {} + for (const key of Object.keys(pkg)) { + if (key === 'peerDependencies' || key === 'peerDependenciesMeta') continue + ordered[key] = pkg[key] + if (key === 'dependencies') { + ordered.peerDependencies = pkg.peerDependencies + ordered.peerDependenciesMeta = pkg.peerDependenciesMeta + } + } + // If there was no dependencies key, ensure they're added before devDependencies + if (!ordered.peerDependencies) { + const final = {} + for (const key of Object.keys(ordered)) { + if (key === 'devDependencies') { + final.peerDependencies = pkg.peerDependencies + final.peerDependenciesMeta = pkg.peerDependenciesMeta + } + final[key] = ordered[key] + } + if (!final.peerDependencies) { + final.peerDependencies = pkg.peerDependencies + final.peerDependenciesMeta = pkg.peerDependenciesMeta + } + writeFileSync(pkgPath, JSON.stringify(final, null, 2) + '\n') + } else { + writeFileSync(pkgPath, JSON.stringify(ordered, null, 2) + '\n') + } + console.log(` ✓ written to ${pkgPath}`) + } +} diff --git a/lib/SimpleCliCommand.js b/lib/SimpleCliCommand.js new file mode 100644 index 0000000..37b2218 --- /dev/null +++ b/lib/SimpleCliCommand.js @@ -0,0 +1,11 @@ +import CliCommand from './CliCommand.js' + +export default class SimpleCliCommand extends CliCommand { + async run (...args) { + const paramKeys = Object.keys(this.config.params) + const params = paramKeys.reduce((m, k, i) => Object.assign(m, { [k]: args[i] }), {}) + const [opts, command] = args.slice(paramKeys.length) + this.options = { ...opts, ...params, action: command.name() } + await this.runTask() + } +} diff --git a/lib/Utils.js b/lib/Utils.js index bdad4e5..a5e81f1 100644 --- a/lib/Utils.js +++ b/lib/Utils.js @@ -1,14 +1,18 @@ +import buildPackageIndex from './utils/buildPackageIndex.js' import checkPrerequisites from './utils/checkPrerequisites.js' import cloneRepo from './utils/cloneRepo.js' +import collectJsFiles from './utils/collectJsFiles.js' import exec from './utils/exec.js' import getAppDependencies from './utils/getAppDependencies.js' import getCliRoot from './utils/getCliRoot.js' import getReleases from './utils/getReleases.js' +import getModuleDirs from './utils/getModuleDirs.js' import getSchemas from './utils/getSchemas.js' import getStartCommands from './utils/getStartCommands.js' import githubRequest from './utils/githubRequest.js' import importCore from './utils/importCore.js' import installLocalModules from './utils/installLocalModules.js' +import isModule from './utils/isModule.js' import loadJson from './utils/loadJson.js' import loadPackage from './utils/loadPackage.js' import startApp from './utils/startApp.js' @@ -19,17 +23,21 @@ import saveConfig from './utils/saveConfig.js' import updateRepo from './utils/updateRepo.js' export default { + buildPackageIndex, checkPrerequisites, cloneRepo, + collectJsFiles, exec, getAppDependencies, getCliRoot, + getModuleDirs, getReleases, getSchemas, getStartCommands, githubRequest, importCore, installLocalModules, + isModule, loadJson, loadPackage, parseBody, diff --git a/lib/peerDeps.js b/lib/peerDeps.js new file mode 100644 index 0000000..53fe882 --- /dev/null +++ b/lib/peerDeps.js @@ -0,0 +1,138 @@ +import { readFileSync, existsSync, statSync } from 'node:fs' +import { join } from 'node:path' +import collectJsFiles from './utils/collectJsFiles.js' + +export const PREFIX = 'adapt-authoring-' + +/** + * Extract all waitForModule module names from a list of JS files + */ +export function extractModuleNames (files) { + const names = new Set() + const callRegex = /waitForModule\(([^)]+)\)/g + const argRegex = /'([^']+)'/g + + for (const file of files) { + const src = readFileSync(file, 'utf8') + for (const callMatch of src.matchAll(callRegex)) { + const argsStr = callMatch[1] + for (const argMatch of argsStr.matchAll(argRegex)) { + names.add(argMatch[1]) + } + } + } + return names +} + +/** + * Extract all imported adapt-authoring-* module names from a list of JS files. + * Matches ES import and CommonJS require statements. + */ +export function extractImportedModules (files) { + const names = new Set() + const importRegex = /(?:from|require\()\s*['"]((adapt-authoring-[^/'"]+)(?:\/[^'"]*)?)['"]/g + + for (const file of files) { + const src = readFileSync(file, 'utf8') + for (const match of src.matchAll(importRegex)) { + names.add(match[2]) + } + } + return names +} + +/** + * Apply the adapt-authoring- prefix if missing + */ +export function toFullName (shortName) { + return shortName.startsWith(PREFIX) ? shortName : PREFIX + shortName +} + +/** + * Derive the expected peerDependencies for a module directory. + * Returns null if the module has no lib/ dir or no waitForModule calls. + * Returns { moduleName, pkgPath, pkg, peerDeps, warnings } on success. + */ +export function deriveExpectedPeerDeps (moduleDir, pkgIndex) { + const pkgPath = join(moduleDir, 'package.json') + if (!existsSync(pkgPath)) return null + + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const moduleName = pkg.name + const libDir = join(moduleDir, 'lib') + + if (!existsSync(libDir) || !statSync(libDir).isDirectory()) { + return null + } + + const jsFiles = collectJsFiles(libDir) + if (jsFiles.length === 0) return null + + const rawNames = extractModuleNames(jsFiles) + if (rawNames.size === 0) return null + + const directDeps = pkg.dependencies || {} + const peerDeps = {} + const warnings = [] + + for (const name of [...rawNames].sort()) { + const fullName = toFullName(name) + if (fullName === moduleName) continue + if (Object.hasOwn(directDeps, fullName)) continue + + const info = pkgIndex.get(fullName) + if (info) { + peerDeps[fullName] = `^${info.version}` + } else { + peerDeps[fullName] = '*' + warnings.push(`${fullName}: package not found locally, using "*"`) + } + } + + if (Object.keys(peerDeps).length === 0) return null + + return { moduleName, pkgPath, pkg, peerDeps, warnings } +} + +/** + * Derive the expected adapt-authoring-* dependencies for a module directory + * based on import/require statements in lib/. + * Returns null if the module has no lib/ dir or no adapt-authoring imports. + * Returns { moduleName, pkgPath, pkg, expectedDeps, warnings } on success. + */ +export function deriveExpectedDeps (moduleDir, pkgIndex) { + const pkgPath = join(moduleDir, 'package.json') + if (!existsSync(pkgPath)) return null + + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const moduleName = pkg.name + const libDir = join(moduleDir, 'lib') + + if (!existsSync(libDir) || !statSync(libDir).isDirectory()) { + return null + } + + const jsFiles = collectJsFiles(libDir) + if (jsFiles.length === 0) return null + + const importedNames = extractImportedModules(jsFiles) + if (importedNames.size === 0) return null + + const expectedDeps = {} + const warnings = [] + + for (const fullName of [...importedNames].sort()) { + if (fullName === moduleName) continue + + const info = pkgIndex.get(fullName) + if (info) { + expectedDeps[fullName] = `^${info.version}` + } else { + warnings.push(`${fullName}: package not found locally`) + } + } + + if (Object.keys(expectedDeps).length === 0) return null + + return { moduleName, pkgPath, pkg, expectedDeps, warnings } +} diff --git a/lib/utils/buildPackageIndex.js b/lib/utils/buildPackageIndex.js new file mode 100644 index 0000000..b02478c --- /dev/null +++ b/lib/utils/buildPackageIndex.js @@ -0,0 +1,21 @@ +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Build a map of package name -> { version, dir } for all local modules + */ +export default function buildPackageIndex (rootDir) { + const index = new Map() + for (const entry of readdirSync(rootDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const pkgPath = join(rootDir, entry.name, 'package.json') + if (!existsSync(pkgPath)) continue + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + if (pkg.name) { + index.set(pkg.name, { version: pkg.version, dir: entry.name }) + } + } catch {} + } + return index +} diff --git a/lib/utils/collectJsFiles.js b/lib/utils/collectJsFiles.js new file mode 100644 index 0000000..3537655 --- /dev/null +++ b/lib/utils/collectJsFiles.js @@ -0,0 +1,10 @@ +import { globSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Recursively collect all .js files under a directory + */ +export default function collectJsFiles (dir) { + const pattern = join(dir, '**', '*.js') + return globSync(pattern) +} diff --git a/lib/utils/getModuleDirs.js b/lib/utils/getModuleDirs.js new file mode 100644 index 0000000..a3acd71 --- /dev/null +++ b/lib/utils/getModuleDirs.js @@ -0,0 +1,13 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import isModule from './isModule.js' + +/** + * Scan child directories for valid modules (dirs containing adapt-authoring.json) + */ +export default function getModuleDirs (parentDir) { + return readdirSync(parentDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => join(parentDir, e.name)) + .filter(dir => isModule(dir)) +} diff --git a/lib/utils/isModule.js b/lib/utils/isModule.js new file mode 100644 index 0000000..b63d7e5 --- /dev/null +++ b/lib/utils/isModule.js @@ -0,0 +1,9 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Check if a directory is a valid module by testing for adapt-authoring.json + */ +export default function isModule (dir) { + return existsSync(join(dir, 'adapt-authoring.json')) +}