diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58ef98d..01d250d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,7 +285,7 @@ jobs: name: mutation changed if: github.event_name == 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 120 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: diff --git a/README.md b/README.md index ac3d7d4..c0e4825 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,8 @@ The repository CI runs `npm run benchmark:scale` as a synthetic regression tripw Run the benchmark on your own hardware for real planning numbers. `check --changed --base origin/main` reports only newly introduced findings and reuses only clean deterministic base analysis keyed to the exact analyzer, schema, Python runtime, and policy inputs, while still analyzing the current tree in full. Base results containing findings are never cacheable because cached findings must not suppress current findings. +Passing `--head ` selects that committed snapshot for both the diff and analysis. CellFence uses a temporary detached worktree and leaves your checkout and uncommitted changes untouched. Omitting `--head` analyzes the current working tree, including uncommitted changes. + ## CI Minimal GitHub Actions job: diff --git a/docs/coverage.md b/docs/coverage.md index 19b5184..ea2cefa 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -38,13 +38,15 @@ Coverage observations are grouped into three buckets: Ordinary rule findings that do not represent analysis visibility, such as a plugin warning or an intentional policy violation, do not reduce the coverage ratio. +The file inventory uses the same ownership, governance exclusions, and generated-directory rules as `check`. Excluded files do not count as analyzed or contribute to the denominator. `totalFiles` includes the in-scope source inventory and unresolved inputs outside that inventory; `analyzedFiles` contains in-scope files without unresolved observations. Diagnostics preserve the owning cell and source line when available. + ## How To Improve Coverage Typical remediation paths are: - rewrite computed imports or resource names into static, reviewable forms; - add explicit `resourceContracts` for intentional high-value couplings; -- let the baseline grandfather known existing resources, then review only new deltas; +- declare approved resource access, create a passing baseline, then review new deltas (see [ratchets](ratchets.md)); - pass runtime evidence through `--evidence` for resources that are only visible while tests or services run; - enable built-in adapters that match the stack, or write a programmatic adapter with `@cellfence/plugin-api`; - use a short-lived signed waiver only when the blind spot is reviewed and temporary. diff --git a/docs/limitations.md b/docs/limitations.md index 571ec1e..45062ae 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -17,6 +17,8 @@ Version 0.x is deliberately narrow: - public symbol analysis supports common TypeScript forms, exported namespaces, and Python AST top-level declarations / literal `__all__`, not every possible dynamic export pattern. In Python, module-level imports are public attributes unless hidden by underscore aliases or constrained by `__all__`; - TypeScript/JavaScript public surface hashes use isolated normalized declaration output when available and remain contract fingerprints, not full API-compatibility proofs; imported implementation details can still collapse to broad declaration types without a separate typecheck; - computed dynamic imports and computed CommonJS `require()` calls cannot be resolved statically; +- `createRequire` preserves statically known filename and file-URL origins; unknown origins, assigned loader aliases, and loaders passed to unknown functions are reported as unresolved. HTTP URL constructors resolve both input and base, and string alternatives exceeding the 16-value analysis limit are unresolved rather than partially accepted; +- Python standard-library classification uses the inspector interpreter's `sys.stdlib_module_names` on Python 3.10 or later. Older interpreters without that metadata recognize builtin modules only; other imports remain subject to dependency policy; - `check --changed` performs full head analysis, then compares stable finding fingerprints to report only newly introduced findings. Only clean base results without findings are cacheable; the key binds the base commit, engine and schema implementation, Node/TypeScript/Python runtime, policy inputs, and severity configuration. Absolute or repository-escaping policy paths and plugins without an explicit `pluginCacheKey` remain uncached; - Markdown and SARIF output are report formats over the same deterministic findings, not separate analyzers; - `cellfence coverage` reports unresolved analysis observations; it is not a proof that unsupported code paths are safe; diff --git a/docs/ratchets.md b/docs/ratchets.md index fa1217d..e421eb2 100644 --- a/docs/ratchets.md +++ b/docs/ratchets.md @@ -67,12 +67,12 @@ The private key belongs to an approval-controlled workflow or external signing s If a cell has `"locked": true`, `baseline check` requires either `CELLFENCE_BASELINE_ED25519_PUBLIC_KEY` or `CELLFENCE_BASELINE_HMAC_KEY` so a hand-edited baseline cannot silently redefine the accepted contract for that locked cell. `baseline update` also fails with `CELLFENCE_LOCKED_BASELINE_EXPANSION` whenever the update would increase or shift ownership scope, add public symbols, change the public entry, change public signatures, add dependency edges, add artifact contracts, increase legacy count metrics, or grandfather new resource access or external dependency use for that cell. A human owner must either reduce the change or explicitly review and sign the contract expansion. -For large repositories, prefer this baseline-first workflow over hand-writing every resource contract: +For large repositories, establish a passing resource policy before creating a baseline: 1. declare cells, public entries, and ownership in the manifest; -2. run `cellfence baseline create` to snapshot existing static file, database, queue, HTTP resource access, and observed external dependency use; -3. optionally pass runtime evidence with `--evidence resource-evidence.json`; -4. run `cellfence baseline check` in CI; -5. review only new resource access deltas. +2. run `cellfence check` to identify existing file, database, queue, and HTTP resource access, and declare the approved access in `resourceContracts`; configure `externalDependencies` when dependency policy requires it; +3. resolve the reported policy violations and unresolved analysis; optionally pass runtime evidence with `--evidence resource-evidence.json`; +4. run `cellfence baseline create` to snapshot the passing repository, using the same manifest and evidence; +5. run `cellfence baseline check` in CI and review new contract deltas. -`resourceContracts` and `externalDependencies` remain useful for intentional high-value contracts, but the baseline prevents a manifest maintenance treadmill where every historical table, topic, endpoint, or third-party dependency must be manually listed before adoption. +`baseline create` runs the normal checks and refuses repositories with unapproved resource access. It does not automatically authorize existing access or replace required manifest contracts. The baseline records an accepted state and then prevents silent expansion beyond it. diff --git a/packages/cli/src/coverage-walker.ts b/packages/cli/src/coverage-walker.ts index 9845198..0bc39f3 100644 --- a/packages/cli/src/coverage-walker.ts +++ b/packages/cli/src/coverage-walker.ts @@ -88,29 +88,25 @@ function configurationInputWasExplicit(options: WalkOptions): boolean { export function walkCoverage(options: WalkOptions): WalkResult { const check = checkRepository(options); const unresolved: CoverageUnresolved[] = []; - for (const finding of [...check.findings, ...check.warnings]) { - const bucket = bucketForRule(finding.ruleId); - if (!bucket) continue; - if (bucket.configuration && !configurationInputWasExplicit(options)) continue; - recordUnresolved(unresolved, { - kind: bucket.kind, - cellId: undefined, - filePath: finding.filePath ? path.resolve(options.rootDir, finding.filePath) : options.rootDir, - line: undefined, - shape: bucket.configuration ? "configuration" : shapeForRule(finding.ruleId, finding.message), - reason: finding.message, - }); - } const manifestPath = path.resolve(options.rootDir, options.manifestPath || "cellfence.manifest.json"); const sourceInventory = new Set(); + const cellByPath = new Map(); try { const manifest = loadManifestFromFile(manifestPath); + const context = { + rootDir: options.rootDir, + manifest, + sourceFilesForCellCache: new Map(), + sourceTextCache: new Map(), + sourceFileCache: new Map(), + }; for (const cell of manifest.cells) { - for (const filePath of sourceFilesForCell(options.rootDir, cell)) { + for (const filePath of sourceFilesForCell(options.rootDir, cell, context)) { sourceInventory.add(repoPath(options.rootDir, filePath)); + cellByPath.set(repoPath(options.rootDir, filePath), cell.id); } } - for (const filePath of sourceFilesUnderGovernance(options.rootDir, manifest)) { + for (const filePath of sourceFilesUnderGovernance(options.rootDir, manifest, context)) { sourceInventory.add(repoPath(options.rootDir, filePath)); } } catch { @@ -118,6 +114,19 @@ export function walkCoverage(options: WalkOptions): WalkResult { // computation side-effect-free and let the caller surface the original // finding instead of masking it with an inventory failure. } + for (const finding of [...check.findings, ...check.warnings]) { + const bucket = bucketForRule(finding.ruleId); + if (!bucket) continue; + if (bucket.configuration && !configurationInputWasExplicit(options)) continue; + recordUnresolved(unresolved, { + kind: bucket.kind, + cellId: finding.cellId ?? (finding.filePath ? cellByPath.get(repoPath(options.rootDir, path.resolve(options.rootDir, finding.filePath))) : undefined), + filePath: finding.filePath ? path.resolve(options.rootDir, finding.filePath) : options.rootDir, + line: typeof finding.details?.line === "number" ? finding.details.line : undefined, + shape: bucket.configuration ? "configuration" : shapeForRule(finding.ruleId, finding.message), + reason: finding.message, + }); + } const unresolvedFiles = new Set(unresolved.map((entry) => repoPath(options.rootDir, entry.filePath))); const externalUnresolvedCount = unresolved .map((entry) => repoPath(options.rootDir, entry.filePath)) diff --git a/packages/engine/src/advanced-governance.ts b/packages/engine/src/advanced-governance.ts index e71b228..282a161 100644 --- a/packages/engine/src/advanced-governance.ts +++ b/packages/engine/src/advanced-governance.ts @@ -15,7 +15,7 @@ import { type ResourceContractManifest, type RuleSeverityMap, } from "@cellfence/schema"; -import { listFiles, matchesPattern, normalizePath, patternCoveredByOwnedPaths, repoPath, SOURCE_EXTENSIONS } from "./file-index.js"; +import { listFiles, matchesPattern, normalizePath, pathOwnedByCell, patternCoveredByOwnedPaths, repoPath, SOURCE_EXTENSIONS } from "./file-index.js"; import { PRODUCTION_SCOPE_EXCLUDES, type InferManifestScope } from "./manifest-inference.js"; import { extractPublicSymbols, publicSurfaceHash } from "./module-resolution.js"; import { ownedPathPatternsOverlap } from "./glob-overlap.js"; @@ -638,7 +638,7 @@ function owningCellsForFiles(manifest: CellFenceManifest, files: string[]): stri const cells = new Set(); for (const filePath of files) { for (const cell of manifest.cells) { - if (cell.ownedPaths.some((pattern) => matchesPattern(filePath, pattern))) cells.add(cell.id); + if (pathOwnedByCell(cell, filePath)) cells.add(cell.id); } } return [...cells].sort((left, right) => left.localeCompare(right)); @@ -687,7 +687,7 @@ export function checkCommitEvidence(options: { rootDir?: string; manifest: CellF } } const declaredCells = csv(trailers["Changed-Cells"]); - if (declaredCells.length > 0 && JSON.stringify(declaredCells) !== JSON.stringify(changedCells)) { + if (JSON.stringify(declaredCells) !== JSON.stringify(changedCells)) { findings.push({ ruleId: "CELLFENCE_COMMIT_CHANGED_CELLS_MISMATCH", severity: "error", message: `${commit.slice(0, 12)} Changed-Cells does not match git diff`, details: { commit, declaredCells, changedCells } }); } const addedTests = files.filter((entry) => entry.status.startsWith("A") && /(^|\/)(tests?|__tests__)\//.test(entry.path)).map((entry) => entry.path).sort(); diff --git a/packages/engine/src/claims.ts b/packages/engine/src/claims.ts index 15b18cd..5cecb69 100644 --- a/packages/engine/src/claims.ts +++ b/packages/engine/src/claims.ts @@ -15,7 +15,7 @@ import { patternCoveredByOwnedPaths, repoPath, } from "./file-index.js"; -import { pathPatternsOverlap } from "./glob-overlap.js"; +import { ownedPathPatternsOverlap, pathPatternsOverlap } from "./glob-overlap.js"; import { stableCanonicalJson } from "./governance/canonicalization.js"; import { readJsonFile } from "./json-file.js"; import { @@ -553,14 +553,14 @@ function claimConflictSurfaces(left: CellFenceClaim, right: CellFenceClaim, cont const rightOwnedPathPrefixes = ownedPathPrefixesFor(context, right.cells); for (const leftPath of left.paths) { for (const rightOwned of rightOwnedPathPrefixes) { - if (pathPatternsOverlap(leftPath, rightOwned.pattern)) { + if (ownedPathPatternsOverlap(leftPath, rightOwned.pattern)) { surfaces.push(`path:${leftPath}<->cell:${rightOwned.cellId}`); } } } for (const rightPath of right.paths) { for (const leftOwned of leftOwnedPathPrefixes) { - if (pathPatternsOverlap(rightPath, leftOwned.pattern)) { + if (ownedPathPatternsOverlap(rightPath, leftOwned.pattern)) { surfaces.push(`path:${rightPath}<->cell:${leftOwned.cellId}`); } } diff --git a/packages/engine/src/external-dependencies.ts b/packages/engine/src/external-dependencies.ts index 1b661a3..46e2610 100644 --- a/packages/engine/src/external-dependencies.ts +++ b/packages/engine/src/external-dependencies.ts @@ -6,6 +6,7 @@ import type { CellFenceBaseline, CellManifest } from "@cellfence/schema"; import { addFinding } from "./findings.js"; import type { AnalysisContext, Finding, ResolvedImport } from "./types.js"; import type { ImportReference } from "./module-resolution.js"; +import { pythonStdlibModuleNames } from "./python-inspector-runner.js"; type ExternalDependencyId = string; @@ -14,69 +15,6 @@ const NODE_BUILTINS = new Set([ ...builtinModules.map((specifier) => specifier.replace(/^node:/, "")), ]); -const PYTHON_STDLIB_ROOTS = new Set([ - "__future__", - "abc", - "argparse", - "asyncio", - "base64", - "bisect", - "bz2", - "calendar", - "collections", - "concurrent", - "copy", - "contextlib", - "csv", - "dataclasses", - "datetime", - "decimal", - "email", - "enum", - "fnmatch", - "functools", - "glob", - "gzip", - "hashlib", - "heapq", - "http", - "importlib", - "inspect", - "io", - "itertools", - "json", - "logging", - "math", - "multiprocessing", - "os", - "pathlib", - "pickle", - "platform", - "queue", - "random", - "re", - "shutil", - "signal", - "sqlite3", - "statistics", - "socket", - "ssl", - "string", - "subprocess", - "sys", - "tempfile", - "threading", - "time", - "tomllib", - "traceback", - "types", - "typing", - "unittest", - "urllib", - "uuid", - "xml", - "zipfile", -]); export type ExternalDependencyObservation = { cellId: string; @@ -118,7 +56,7 @@ function pythonImportRoot(specifier: string): string | undefined { export function isPythonStdlibSpecifier(specifier: string): boolean { const root = pythonImportRoot(specifier); - return Boolean(root && PYTHON_STDLIB_ROOTS.has(root)); + return Boolean(root && pythonStdlibModuleNames().has(root)); } function npmDependencyId(specifier: string): ExternalDependencyId | undefined { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index d5c81d5..3c200eb 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -908,6 +908,8 @@ function resolveWorkspacePackageImport(context: AnalysisContext, reference: Impo } function resolveImport(context: AnalysisContext, reference: ImportReference): ResolvedImport { + const referenceWithResolutionBase = reference as ImportReference & { resolutionBasePath?: string }; + const resolutionBasePath = referenceWithResolutionBase.resolutionBasePath ?? reference.importerPath; if (path.extname(reference.importerPath) === ".py") { const specifiers = [...(reference.candidateSpecifiers || []), reference.specifier]; for (const specifier of specifiers) { @@ -918,14 +920,14 @@ function resolveImport(context: AnalysisContext, reference: ImportReference): Re } if (importSpecifierLooksPathLike(reference.specifier)) { - const targetPath = resolveRelativeImport(context.rootDir, reference.importerPath, reference.specifier); + const targetPath = resolveRelativeImport(context.rootDir, resolutionBasePath, reference.specifier); if (!targetPath) return { isExternal: false, isPublicPackage: false }; return resolvedRepositoryImport(context, targetPath); } const packageImportTargetPath = resolvePackageImportsTarget( context.rootDir, - reference.importerPath, + resolutionBasePath, reference.specifier, reference.typeOnly ? "types" : reference.kind === "require" ? "require" : "import", ); @@ -934,7 +936,7 @@ function resolveImport(context: AnalysisContext, reference: ImportReference): Re const packageImport = resolveWorkspacePackageImport(context, reference); if (packageImport) return packageImport; - const aliasTargetPath = resolveNearestPathAliasTarget(context.rootDir, reference.importerPath, reference.specifier) + const aliasTargetPath = resolveNearestPathAliasTarget(context.rootDir, resolutionBasePath, reference.specifier) || resolvePathAliasTarget(context, reference.specifier); if (aliasTargetPath) return resolvedRepositoryImport(context, aliasTargetPath, { matchedSpecifier: reference.specifier }); @@ -2160,7 +2162,28 @@ export function checkChangedRepository(options: ChangedCheckOptions = {}): Check try { gitCommand(rootDir, ["rev-parse", "--is-inside-work-tree"]); const baseCommit = assertGitCommit(rootDir, baseRef); - if (options.headRef) assertGitCommit(rootDir, options.headRef); + if (options.headRef) { + const headCommit = assertGitCommit(rootDir, options.headRef); + // An explicit ref selects a committed snapshot, regardless of checkout or dirt. + return withBaseWorktree(rootDir, headCommit, (headRootDir) => { + const snapshotPath = (input: string | undefined): string | undefined => { + if (!input || !path.isAbsolute(input)) return input; + const relative = path.relative(rootDir, input); + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) + ? path.resolve(headRootDir, relative) + : input; + }; + return checkChangedRepository({ + ...options, + rootDir: headRootDir, + baseRef: baseCommit, + headRef: undefined, + manifestPath: snapshotPath(options.manifestPath), + baselinePath: snapshotPath(options.baselinePath), + evidencePaths: options.evidencePaths?.map((input) => snapshotPath(input)!), + }); + }); + } const changedFiles = changedFilesForRefs(rootDir, baseRef, options.headRef); const movements = movementEntriesForRefs(rootDir, baseRef, options.headRef); const currentResult = checkRepository(checkOptionsForChangedCurrent(options, changedFiles)); diff --git a/packages/engine/src/module-resolution.ts b/packages/engine/src/module-resolution.ts index 418b126..c314bb6 100644 --- a/packages/engine/src/module-resolution.ts +++ b/packages/engine/src/module-resolution.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import ts from "typescript"; import { @@ -32,6 +32,10 @@ export type ImportReference = { line: number; }; +type ImportReferenceWithResolutionBase = ImportReference & { + resolutionBasePath?: string; +}; + export type ImportWarning = { ruleId: | "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE" @@ -68,7 +72,12 @@ type PathAliasContext = { pathAliases: PathAlias[]; }; -type ImportBindingKind = "require" | "createRequire" | "moduleNamespace" | "nodeModule" | null; +type RequireBinding = { requireBase: string | undefined }; +type ImportBindingKind = "require" | "createRequire" | "moduleNamespace" | "nodeModule" | RequireBinding | null; + +function isRequireBinding(kind: ImportBindingKind | undefined): kind is "require" | RequireBinding { + return kind === "require" || (typeof kind === "object" && kind !== null); +} type ImportScope = { bindings: Map; @@ -220,7 +229,7 @@ export function candidateModulePaths(basePath: string): string[] { function candidatePythonModulePaths(basePath: string): string[] { const normalizedBasePath = normalizePath(basePath); - return [`${normalizedBasePath}.py`, `${normalizedBasePath}/__init__.py`]; + return [`${normalizedBasePath}/__init__.py`, `${normalizedBasePath}.py`]; } function existingFileFromCandidates(candidates: string[]): string | undefined { @@ -626,18 +635,19 @@ export function extractImports( details: { line: position.line + 1, offset: position.character + 1 }, }); } - const references: ImportReference[] = []; + const references: ImportReferenceWithResolutionBase[] = []; const importerPath = repoPath(context.rootDir, filePath); const rootScope = createRootImportScope(); rootScope.bindings.set("require", "require"); - function addReference(specifier: string, kind: ImportKind, node: ts.Node, typeOnly: boolean): void { + function addReference(specifier: string, kind: ImportKind, node: ts.Node, typeOnly: boolean, resolutionBasePath?: string): void { references.push({ importerPath, specifier, kind, typeOnly, line: getLineNumber(sourceFile, node), + ...(resolutionBasePath ? { resolutionBasePath } : {}), }); } @@ -1033,7 +1043,8 @@ export function extractImports( function isRequireLikeExpression(scope: ImportScope, expression: ts.Expression): boolean { const unwrapped = unwrapExpression(expression); - return (ts.isIdentifier(unwrapped) && bindingFor(scope, unwrapped.text) === "require") + return (ts.isIdentifier(unwrapped) && isRequireBinding(bindingFor(scope, unwrapped.text))) + || (ts.isCallExpression(unwrapped) && Boolean(createRequireKind(scope, unwrapped.expression))) || isModuleRequireProperty(scope, unwrapped) || isGlobalRequireProperty(scope, unwrapped) || isProcessMainModuleRequireProperty(scope, unwrapped) @@ -1066,12 +1077,11 @@ export function extractImports( function bindingKindFromInitializer(scope: ImportScope, initializer: ts.Expression): ImportBindingKind | undefined { const unwrapped = unwrapExpression(initializer); - if (isRequireLikeExpression(scope, unwrapped)) return "require"; + if (isRequireLikeExpression(scope, unwrapped)) return requireBindingForExpression(scope, unwrapped); if (isNodeModuleObject(scope, unwrapped)) return "nodeModule"; if (createRequireKind(scope, unwrapped)) return "createRequire"; if (ts.isCallExpression(unwrapped)) { - if (createRequireKind(scope, unwrapped.expression)) return "require"; - if (staticPropertyName(unwrapped.expression) === "bind" && Boolean(staticPropertyReceiver(unwrapped.expression)) && isRequireLikeExpression(scope, staticPropertyReceiver(unwrapped.expression)!)) return "require"; + if (staticPropertyName(unwrapped.expression) === "bind" && Boolean(staticPropertyReceiver(unwrapped.expression)) && isRequireLikeExpression(scope, staticPropertyReceiver(unwrapped.expression)!)) return requireBindingForExpression(scope, staticPropertyReceiver(unwrapped.expression)!); const moduleSpecifier = literalRequireLikeSpecifier(scope, unwrapped); if (moduleSpecifier && isModulePackageSpecifier(moduleSpecifier)) return "moduleNamespace"; } @@ -1080,7 +1090,8 @@ export function extractImports( function requireLikeName(scope: ImportScope, expression: ts.Expression): string | undefined { const unwrapped = unwrapExpression(expression); - if (ts.isIdentifier(unwrapped) && bindingFor(scope, unwrapped.text) === "require") return unwrapped.text; + if (ts.isIdentifier(unwrapped) && isRequireBinding(bindingFor(scope, unwrapped.text))) return unwrapped.text; + if (ts.isCallExpression(unwrapped) && createRequireKind(scope, unwrapped.expression)) return "createRequire(...)"; if (isModuleRequireProperty(scope, unwrapped)) return "module.require"; if (isProcessMainModuleRequireProperty(scope, unwrapped)) return "process.mainModule.require"; if (isModuleConstructorLoadProperty(scope, unwrapped)) return "module.constructor._load"; @@ -1093,6 +1104,44 @@ export function extractImports( return undefined; } + function requireOrigin(scope: ImportScope, expression: ts.Expression | undefined): string | undefined { + if (!expression) return undefined; + const unwrapped = unwrapExpression(expression); + if (ts.isIdentifier(unwrapped) && unwrapped.text === "__filename" && bindingFor(scope, "__filename") === undefined) return filePath; + if (ts.isPropertyAccessExpression(unwrapped) && unwrapped.name.text === "url" + && ts.isMetaProperty(unwrapped.expression) && unwrapped.expression.keywordToken === ts.SyntaxKind.ImportKeyword) return filePath; + const literal = staticModuleSpecifier(scope, unwrapped); + if (literal !== undefined) { + if (!literal.startsWith("file:")) return path.isAbsolute(literal) ? literal : undefined; + try { + return fileURLToPath(literal); + } catch { /* Invalid file URLs fall through to the unresolved result below. */ } + } + if (ts.isNewExpression(unwrapped) && ts.isIdentifier(unwrapped.expression) + && unwrapped.expression.text === "URL" && bindingFor(scope, "URL") === undefined) { + const input = unwrapped.arguments?.[0]; + const base = unwrapped.arguments?.[1]; + const inputValue = staticModuleSpecifier(scope, input); + const basePath = requireOrigin(scope, base); + if (inputValue === undefined || (base && basePath === undefined)) return undefined; + try { return fileURLToPath(new URL(inputValue, basePath ? pathToFileURL(basePath) : undefined)); } + catch { /* Invalid URL combinations fall through to the unresolved result below. */ } + } + return undefined; + } + + function requireBindingForExpression(scope: ImportScope, expression: ts.Expression): "require" | RequireBinding { + const unwrapped = unwrapExpression(expression); + // Callers have already recognized a require-like expression. Preserve its + // binding instead of repeating recognition and silently defaulting an alias. + if (ts.isIdentifier(unwrapped)) return bindingFor(scope, unwrapped.text) as "require" | RequireBinding; + if (ts.isCallExpression(unwrapped)) { + const origin = requireOrigin(scope, unwrapped.arguments[0]); + return origin === filePath ? "require" : { requireBase: origin === undefined ? undefined : repoPath(context.rootDir, origin) }; + } + return "require"; + } + function literalFromApplyArray(scope: ImportScope, node: ts.Expression | undefined): string | undefined { if (!node) return undefined; const unwrapped = unwrapExpression(node); @@ -1100,12 +1149,12 @@ export function extractImports( return staticModuleSpecifier(scope, unwrapped.elements[0]); } - function requireCallArgument(scope: ImportScope, node: ts.CallExpression): { sourceName: string; specifier?: string } | undefined { + function requireCallArgument(scope: ImportScope, node: ts.CallExpression): { sourceName: string; specifier?: string; binding: "require" | RequireBinding } | undefined { const directName = requireLikeName(scope, node.expression); if (directName) { if (node.arguments.length < 1) return undefined; const specifier = staticModuleSpecifier(scope, node.arguments[0]); - return { sourceName: directName, specifier }; + return { sourceName: directName, specifier, binding: requireBindingForExpression(scope, node.expression) }; } const propertyName = staticPropertyName(node.expression); @@ -1114,10 +1163,10 @@ export function extractImports( const receiverName = requireLikeName(scope, receiver)!; if (propertyName === "call") { const specifier = staticModuleSpecifier(scope, node.arguments[1]); - return { sourceName: `${receiverName}.call`, specifier }; + return { sourceName: `${receiverName}.call`, specifier, binding: requireBindingForExpression(scope, receiver) }; } const specifier = literalFromApplyArray(scope, node.arguments[1]); - return { sourceName: `${receiverName}.apply`, specifier }; + return { sourceName: `${receiverName}.apply`, specifier, binding: requireBindingForExpression(scope, receiver) }; } if ( @@ -1130,7 +1179,7 @@ export function extractImports( && isRequireLikeExpression(scope, node.arguments[0]) ) { const specifier = literalFromApplyArray(scope, node.arguments[2]); - return { sourceName: "Reflect.apply(require)", specifier }; + return { sourceName: "Reflect.apply(require)", specifier, binding: requireBindingForExpression(scope, node.arguments[0]) }; } return undefined; } @@ -1173,23 +1222,33 @@ export function extractImports( if (!guard) return false; const guardedRequireCall = singleReturnRequireCall(scope, node.thenStatement, guard.identifier); if (!guardedRequireCall) return false; - addReference(guard.specifier, "require", guardedRequireCall, false); + addRequireCallReference(guardedRequireCall, "guarded require", guard.specifier, requireBindingForExpression(scope, guardedRequireCall.expression)); if (node.elseStatement) visit(scope, node.elseStatement); return true; } - function addRequireCallReference(node: ts.CallExpression, sourceName: string, specifier: string | undefined): void { + function addUnsupportedRequireWarning(node: ts.CallExpression, sourceName: string): void { + warnings.push({ + ruleId: "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE", + severity: "warning", + filePath: importerPath, + message: `computed ${sourceName}() cannot be resolved statically at line ${getLineNumber(sourceFile, node)}`, + details: { line: getLineNumber(sourceFile, node) }, + }); + } + + function addRequireCallReference(node: ts.CallExpression, sourceName: string, specifier: string | undefined, binding: "require" | RequireBinding): void { if (specifier) { - addReference(specifier, "require", node, false); - } else { - warnings.push({ - ruleId: "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE", - severity: "warning", - filePath: importerPath, - message: `computed ${sourceName}() cannot be resolved statically at line ${getLineNumber(sourceFile, node)}`, - details: { line: getLineNumber(sourceFile, node) }, - }); + if (binding === "require") { + addReference(specifier, "require", node, false); + return; + } + if (binding.requireBase !== undefined) { + addReference(specifier, "require", node, false, binding.requireBase); + return; + } } + addUnsupportedRequireWarning(node, sourceName); } function dynamicExecutionSourceName(scope: ImportScope, node: ts.CallExpression): string | undefined { @@ -1405,9 +1464,21 @@ export function extractImports( } } else { const requireCall = requireCallArgument(scope, node); - if (requireCall) addRequireCallReference(node, requireCall.sourceName, requireCall.specifier); + if (requireCall) addRequireCallReference(node, requireCall.sourceName, requireCall.specifier, requireCall.binding); + else if (node.arguments.some((argument) => isRequireLikeExpression(scope, argument))) { + addUnsupportedRequireWarning(node, "escaped require"); + } addDynamicExecutionRequireReferences(scope, node); } + } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && isRequireLikeExpression(scope, node.right)) { + warnings.push({ + ruleId: "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE", + severity: "warning", + filePath: importerPath, + message: `assigned require alias cannot be resolved statically at line ${getLineNumber(sourceFile, node)}`, + details: { line: getLineNumber(sourceFile, node) }, + }); } else if (ts.isIfStatement(node)) { if (addReadonlySetGuardedRequireIfSafe(scope, node)) return; } else if (ts.isForStatement(node)) { @@ -1705,30 +1776,25 @@ function normalizeDeclarationText(text: string): string { function sourceTextWithoutInternalDeclarations(filePath: string): string { const sourceText = fs.readFileSync(filePath, "utf8"); - // Stryker disable next-line BooleanLiteral: parent pointers are not used while collecting internal declaration line ranges. const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, sourceKindForPath(filePath)); - // Stryker disable next-line ArrayDeclaration: a non-range sentinel in this private, typed collection has no valid line bounds and cannot remove source text. - const lineRanges: Array<{ start: number; end: number }> = []; + const ranges: Array<{ start: number; end: number }> = []; function visit(node: ts.Node): void { if (hasInternalTag(node)) { - lineRanges.push({ - start: sourceFile.getLineAndCharacterOfPosition(node.getFullStart()).line, - end: sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line, + ranges.push({ + start: node.getFullStart(), + end: node.getEnd(), }); return; } ts.forEachChild(node, visit); } visit(sourceFile); - // Stryker disable next-line ConditionalExpression: with no internal ranges, declaration emit observes the same source text after line splitting. - if (lineRanges.length === 0) return sourceText; - const removedLines = new Set(); - for (const range of lineRanges) { - for (let line = range.start; line <= range.end; line += 1) removedLines.add(line); - } - const lines = sourceText.split("\n"); - // Stryker disable next-line StringLiteral: declaration emit normalizes equivalent internal-stripped source text; public surface output is asserted black-box. - return lines.filter((_, index) => !removedLines.has(index)).join("\n"); + let result = sourceText; + // Remove exact spans backwards so adjacent public declarations and offsets survive. + for (const range of ranges.reverse()) { + result = result.slice(0, range.start) + "\n" + result.slice(range.end); + } + return result; } function normalizedDeclarationSourceText(filePath: string): string { @@ -1797,6 +1863,7 @@ function publicSurfaceSignatureParts(filePath: string): string[] { return declarationParts.some(declarationPartIsSubstantive) ? declarationParts : syntaxPublicSurfaceSignatureParts(filePath); } +/** @internal */ export function publicSurfaceHash(filePath: string): string { return crypto.createHash("sha256").update(publicSurfaceSignatureParts(filePath).join("\n")).digest("hex"); } diff --git a/packages/engine/src/python-inspector-runner.ts b/packages/engine/src/python-inspector-runner.ts index f7e4855..8aeb74e 100644 --- a/packages/engine/src/python-inspector-runner.ts +++ b/packages/engine/src/python-inspector-runner.ts @@ -73,6 +73,28 @@ let memoizedPythonCommand: PythonCommand | undefined; let memoizedPythonFailure: Error | undefined; let memoizedRuntimeIdentity: string | undefined; let inspectorProcessCount = 0; +let memoizedStdlibModules: ReadonlySet | undefined; + +export function pythonStdlibModuleNames(): ReadonlySet { + if (memoizedStdlibModules) return memoizedStdlibModules; + // Use the inspector's interpreter in isolated mode; never import repository code. + pythonInspectorRuntimeIdentity(); + if (!memoizedPythonCommand) return new Set(); + const selected = memoizedPythonCommand; + try { + const output = execCommandSync(selected.command, [ + ...selected.args, "-I", "-B", "-c", + "import json, sys; print(json.dumps(sorted(getattr(sys, 'stdlib_module_names', sys.builtin_module_names))))", + ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 }); + const names: unknown = JSON.parse(output); + if (!Array.isArray(names) || !names.every((name) => typeof name === "string")) return new Set(); + memoizedStdlibModules = new Set(names); + return memoizedStdlibModules; + } catch { + // Unknown dependencies stay subject to policy when runtime metadata is unavailable. + return new Set(); + } +} function writeBatchRunner(): string { if (batchRunnerPath) return batchRunnerPath; diff --git a/packages/engine/src/resource-access.ts b/packages/engine/src/resource-access.ts index 898b446..33ea88b 100644 --- a/packages/engine/src/resource-access.ts +++ b/packages/engine/src/resource-access.ts @@ -8,7 +8,6 @@ import { normalizePath, listFiles, parseSourceFile, - readSourceText, repoPath, type FileIndexContext, } from "./file-index.js"; @@ -216,6 +215,11 @@ function textLooksSql(text: string): boolean { return /\b(select|insert|update|delete|from|join|into)\b/i.test(text); } +function boundedStringValues(values: string[]): string[] | undefined { + const unique = [...new Set(values)]; + return unique.length <= 16 ? unique : undefined; +} + // Stryker disable all: fixed-point aliases, branches, concatenation, cycles, and invalid expressions are covered by direct mutation-corpus assertions. function collectStaticStringConstants(sourceFile: ts.SourceFile): Map { const candidateInitializers = new Map(); @@ -244,7 +248,7 @@ function collectStaticStringConstants(sourceFile: ts.SourceFile): Map value.trim().length > 0); } @@ -1233,14 +1248,9 @@ function collectPythonResourceAccesses(context: ResourceAccessAnalysisContext, f } export function collectResourceAccesses(context: ResourceAccessAnalysisContext, filePath: string): ResourceAccessReference[] { - const sourceText = readSourceText(context, filePath); if (isPythonPath(filePath)) { - // Stryker disable next-line ConditionalExpression: this is a process-spawn prefilter; the no-hint Python test proves that inspecting the same source still returns no observations. - if (!PYTHON_RESOURCE_SCAN_HINT.test(sourceText)) return []; return collectPythonResourceAccesses(context, filePath); } - // Stryker disable next-line ConditionalExpression: the hint is a performance prefilter; scanning a no-hint file still produces no resource accesses. - if (!RESOURCE_SCAN_HINT.test(sourceText)) return []; const sourceFile = parseSourceFile(context, filePath); const relativeFilePath = repoPath(context.rootDir, filePath); const accesses: ResourceAccessReference[] = []; @@ -1690,7 +1700,7 @@ export function collectResourceAccesses(context: ResourceAccessAnalysisContext, ...resourceAccessSource(name), }); } - } else if (["get", "post", "put", "patch", "delete"].includes(name) && firstArgumentText.startsWith("/") && routeReceiverLooksHttp(node.expression)) { + } else if (["get", "post", "put", "patch", "delete", "options", "head", "all"].includes(name) && firstArgumentText.startsWith("/") && routeReceiverLooksHttp(node.expression)) { if (httpEnabled) { addResourceAccess(accesses, { kind: "http", diff --git a/packages/plugin-blast-radius/src/index.ts b/packages/plugin-blast-radius/src/index.ts index aa68cf7..8368f3e 100644 --- a/packages/plugin-blast-radius/src/index.ts +++ b/packages/plugin-blast-radius/src/index.ts @@ -18,7 +18,8 @@ function changedCells(repository: CellFenceRepositoryModel): Set { const cells = new Set(); for (const filePath of repository.changedFiles) { for (const cell of repository.manifest.cells) { - if (cell.ownedPaths.some((pattern) => matchesPattern(filePath, pattern))) { + if (cell.ownedPaths.some((pattern) => matchesPattern(filePath, pattern) + || (!pattern.includes("*") && matchesPattern(filePath, `${pattern.replace(/\/$/, "")}/**`)))) { cells.add(cell.id); } } diff --git a/scripts/mutation-scopes.mjs b/scripts/mutation-scopes.mjs index 452f70a..7586d8a 100644 --- a/scripts/mutation-scopes.mjs +++ b/scripts/mutation-scopes.mjs @@ -192,7 +192,10 @@ export const MUTATION_SCOPES = Object.freeze([ "packages/engine/src/python-inspector-runner.ts", ], mutate: "packages/engine/dist/module-resolution.js", - tests: ["tests/module-resolution.test.mjs"], + tests: [ + "tests/module-resolution.test.mjs", + "tests/module-resolution-mutation-regressions.test.mjs", + ], parallelConcurrency: 2, }, { diff --git a/tests/bug-fixtures.mjs b/tests/bug-fixtures.mjs new file mode 100644 index 0000000..cb2f55c --- /dev/null +++ b/tests/bug-fixtures.mjs @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function bugFixture(testContext) { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-bug-")); + testContext.after(() => fs.rmSync(rootDir, { recursive: true, force: true })); + const write = (name, value) => { + const filePath = path.join(rootDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, typeof value === "string" ? value : JSON.stringify(value)); + return filePath; + }; + const manifest = { + schemaVersion: "cellfence.manifest.v1", + governance: { requireOwnership: true, include: ["src/**"] }, + cells: ["producer", "consumer"].map((id) => ({ + id, ownedPaths: [`src/${id}/**`], publicEntry: `src/${id}/public.ts`, + publicSymbols: ["api"], consumes: id === "consumer" ? [{ cell: "producer" }] : [], + })), + }; + write("cellfence.manifest.json", manifest); + write("src/producer/public.ts", "export function api(): number { return 1; }\n"); + write("src/producer/private.ts", "export const secret = 42;\n"); + write("src/consumer/public.ts", "export function api(): number { return 1; }\n"); + write("src/consumer/work.ts", "import { api } from '../producer/public'; api();\n"); + return { rootDir, write, manifest }; +} diff --git a/tests/module-resolution-mutation-regressions.test.mjs b/tests/module-resolution-mutation-regressions.test.mjs new file mode 100644 index 0000000..55232ba --- /dev/null +++ b/tests/module-resolution-mutation-regressions.test.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; + +import { + declarationTextForRoot, + extractImports, +} from "../packages/engine/dist/module-resolution.js"; + +function context(rootDir) { + return { + rootDir, + manifest: { schemaVersion: "cellfence.manifest.v1", cells: [] }, + sourceFilesForCellCache: new Map(), + sourceTextCache: new Map(), + sourceFileCache: new Map(), + }; +} + +function scan(rootDir, source, fileName = "src/app.mts") { + const filePath = path.join(rootDir, fileName); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${source}\n`); + const warnings = []; + const references = extractImports(context(rootDir), filePath, warnings); + return { filePath, references, warnings }; +} + +test("createRequire origin recognition distinguishes globals, shadows, literals, and URL bases", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-create-require-origin-mutants-")); + try { + const same = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "const loader = createRequire(__filename);", + "loader('./dep.cjs');", + ].join("\n")); + assert.deepEqual(same.references.map(({ specifier, resolutionBasePath }) => [specifier, resolutionBasePath]), [ + ["node:module", undefined], + ["./dep.cjs", undefined], + ]); + assert.deepEqual(same.warnings, []); + + const alias = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "const loader = createRequire(__filename);", + "const second = loader;", + "second('./dep.cjs');", + ].join("\n"), "src/alias.mts"); + assert.deepEqual(alias.references.map(({ specifier, resolutionBasePath }) => [specifier, resolutionBasePath]), [ + ["node:module", undefined], + ["./dep.cjs", undefined], + ]); + assert.deepEqual(alias.warnings, []); + + const shadowedFilename = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "function load(__filename: string) {", + " const loader = createRequire(__filename);", + " return loader('./dep.cjs');", + "}", + ].join("\n"), "src/shadowed-filename.mts"); + assert.deepEqual(shadowedFilename.references.map((reference) => reference.specifier), ["node:module"]); + assert.equal(shadowedFilename.warnings.length, 1); + assert.equal(shadowedFilename.warnings[0].severity, "warning"); + assert.match(shadowedFilename.warnings[0].message, /computed loader\(\) cannot be resolved statically/); + + const relativeLiteral = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "const loader = createRequire('./relative.cjs');", + "loader('./dep.cjs');", + ].join("\n"), "src/relative.mts"); + assert.deepEqual(relativeLiteral.references.map((reference) => reference.specifier), ["node:module"]); + assert.equal(relativeLiteral.warnings.length, 1); + assert.equal(relativeLiteral.warnings[0].severity, "warning"); + + const urlBase = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "const loader = createRequire(new URL('./base.cjs', import.meta.url));", + "loader('./dep.cjs');", + ].join("\n"), "src/url-base.mts"); + assert.deepEqual(urlBase.references.map(({ specifier, resolutionBasePath }) => [specifier, resolutionBasePath]), [ + ["node:module", undefined], + ["./dep.cjs", "src/base.cjs"], + ]); + assert.equal(urlBase.references[1].kind, "require"); + assert.equal(urlBase.references[1].typeOnly, false); + assert.deepEqual(urlBase.warnings, []); + + const urlSingleBasePath = path.join(rootDir, "src/url-single-base.cjs"); + const urlSingleArgument = scan(rootDir, [ + "import { createRequire } from 'node:module';", + `const loader = createRequire(new URL(${JSON.stringify(pathToFileURL(urlSingleBasePath).href)}));`, + "loader('./dep.cjs');", + ].join("\n"), "src/url-single.mts"); + assert.equal(urlSingleArgument.references.length, 2); + assert.equal(urlSingleArgument.references[1].specifier, "./dep.cjs"); + assert.equal(urlSingleArgument.references[1].resolutionBasePath, "src/url-single-base.cjs"); + assert.deepEqual(urlSingleArgument.warnings, []); + + const shadowedUrl = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "function load(URL: new (...args: unknown[]) => unknown) {", + " const loader = createRequire(new URL('./base.cjs', import.meta.url) as never);", + " return loader('./dep.cjs');", + "}", + ].join("\n"), "src/shadowed-url.mts"); + assert.deepEqual(shadowedUrl.references.map((reference) => reference.specifier), ["node:module"]); + assert.equal(shadowedUrl.warnings.length, 1); + assert.equal(shadowedUrl.warnings[0].severity, "warning"); + + const wrongNew = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "declare const NotURL: new (...args: unknown[]) => unknown;", + "const loader = createRequire(new NotURL('./base.cjs', import.meta.url) as never);", + "loader('./dep.cjs');", + ].join("\n"), "src/not-url.mts"); + assert.deepEqual(wrongNew.references.map((reference) => reference.specifier), ["node:module"]); + assert.equal(wrongNew.warnings.length, 1); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("createRequire forwarding preserves resolution bases across direct, call, apply, and guarded uses", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-create-require-forward-mutants-")); + try { + const direct = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "createRequire(new URL('./base.cjs', import.meta.url))('./direct.cjs');", + ].join("\n"), "src/direct.mts"); + assert.deepEqual(direct.references.map(({ specifier, resolutionBasePath }) => [specifier, resolutionBasePath]), [ + ["node:module", undefined], + ["./direct.cjs", "src/base.cjs"], + ]); + + const forwarded = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "const loader = createRequire(new URL('./base.cjs', import.meta.url));", + "loader.call(null, './call.cjs');", + "loader.apply(null, ['./apply.cjs']);", + "Reflect.apply(loader, null, ['./reflect.cjs']);", + ].join("\n"), "src/forwarded.mts"); + assert.deepEqual(forwarded.references.map(({ specifier, resolutionBasePath }) => [specifier, resolutionBasePath]), [ + ["node:module", undefined], + ["./call.cjs", "src/base.cjs"], + ["./apply.cjs", "src/base.cjs"], + ["./reflect.cjs", "src/base.cjs"], + ]); + assert.deepEqual(forwarded.warnings, []); + + const guarded = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "declare const origin: string;", + "const loader = createRequire(origin);", + "const allowed = new Set(['./guarded.cjs']);", + "export function load(candidate: string) {", + " if (allowed.has(candidate)) return loader(candidate);", + "}", + ].join("\n"), "src/guarded.mts"); + assert.deepEqual(guarded.references.map((reference) => reference.specifier), ["node:module"]); + assert.equal(guarded.warnings.length, 1); + assert.equal(guarded.warnings[0].severity, "warning"); + assert.match(guarded.warnings[0].message, /computed guarded require\(\) cannot be resolved statically/); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("multiple internal declaration ranges are stripped from the end without corrupting public declarations", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-declaration-range-mutant-")); + try { + const filePath = path.join(rootDir, "api.d.ts"); + fs.writeFileSync(filePath, [ + "/** @internal */ export interface HiddenA { a: string }", + "export interface PublicA { a: string }", + "/** @internal */ export type HiddenB = number;", + "export type PublicB = boolean;", + "/** @internal */ export declare const hiddenC: unique symbol;", + "export declare const publicC: string;", + "", + ].join("\n")); + + assert.equal( + declarationTextForRoot(filePath, {}), + [ + "export interface PublicA {", + " a: string;", + "}", + "export type PublicB = boolean;", + "export declare const publicC: string;", + "", + ].join("\n"), + ); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + + +test("source declarations remove multiple internal spans before declaration emit", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-source-internal-spans-")); + try { + for (const extension of ["ts", "mts", "cts"]) { + const filePath = path.join(rootDir, `api.${extension}`); + fs.writeFileSync(filePath, [ + "/** @internal */ export interface HiddenA { a: string }", + "export interface PublicA { a: string }", + "/** @internal */ export type HiddenB = number;", + "export type PublicB = boolean;", + "/** @internal */ export declare const hiddenC: unique symbol;", + "export declare const publicC: string;", + "", + ].join("\n")); + assert.equal(declarationTextForRoot(filePath, {}), [ + "export interface PublicA {", + " a: string;", + "}", + "export type PublicB = boolean;", + "export declare const publicC: string;", + "", + ].join("\n"), extension); + } + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("assigned require aliases retain complete unresolved diagnostics", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-assigned-loader-diagnostic-")); + try { + for (const initializer of ["require", "createRequire(import.meta.url)"]) { + const result = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "let assigned;", + `assigned = ${initializer};`, + "assigned('./hidden.cjs');", + ].join("\n"), "src/assigned.mts"); + assert.deepEqual(result.references.map((reference) => reference.specifier), ["node:module"]); + assert.deepEqual(result.warnings, [{ + ruleId: "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE", + severity: "warning", + filePath: "src/assigned.mts", + message: "assigned require alias cannot be resolved statically at line 3", + details: { line: 3 }, + }]); + } + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); + +test("recognized loader bindings do not turn ordinary calls or properties into imports", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cellfence-loader-recognition-")); + try { + const ordinary = scan(rootDir, [ + "const createRequire = (origin: string) => (name: string) => name;", + "const loader = createRequire('/tmp/ordinary.cjs');", + "loader('./ordinary.cjs');", + "const other = { require: (name: string) => name };", + "other.require('./property.cjs');", + ].join("\n")); + assert.deepEqual(ordinary.references, []); + assert.deepEqual(ordinary.warnings, []); + + const builtin = scan(rootDir, [ + "module.require('./module.cjs');", + "globalThis.require('./global.cjs');", + "Reflect.apply(require, null, ['./reflected.cjs']);", + ].join("\n"), "src/builtins.cjs"); + assert.deepEqual(builtin.references.map(({ specifier, resolutionBasePath }) => [specifier, resolutionBasePath]), [ + ["./module.cjs", undefined], + ["./global.cjs", undefined], + ["./reflected.cjs", undefined], + ]); + assert.deepEqual(builtin.warnings, []); + + const escaped = scan(rootDir, "consume(require);", "src/escaped.cjs"); + assert.deepEqual(escaped.references, []); + assert.deepEqual(escaped.warnings, [{ + ruleId: "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE", + severity: "warning", + filePath: "src/escaped.cjs", + message: "computed escaped require() cannot be resolved statically at line 1", + details: { line: 1 }, + }]); + + const invalidUrl = scan(rootDir, [ + "import { createRequire } from 'node:module';", + "const loader = createRequire('file://[invalid');", + "loader('./invalid.cjs');", + ].join("\n"), "src/invalid-url.mts"); + assert.deepEqual(invalidUrl.references.map((reference) => reference.specifier), ["node:module"]); + assert.equal(invalidUrl.warnings.length, 1); + assert.equal(invalidUrl.warnings[0].severity, "warning"); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } +}); diff --git a/tests/module-resolution.test.mjs b/tests/module-resolution.test.mjs index 4373f06..60a8ea5 100644 --- a/tests/module-resolution.test.mjs +++ b/tests/module-resolution.test.mjs @@ -31,6 +31,8 @@ import { syntaxPublicSurfaceSignatureParts, } from "../packages/engine/dist/module-resolution.js"; import { inspectPythonSource } from "../packages/engine/dist/python-analysis.js"; +import { checkRepository, createBaseline } from "../packages/engine/dist/index.js"; +import { bugFixture } from "./bug-fixtures.mjs"; function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -1282,7 +1284,10 @@ test("module resolution reports dynamic require compatibility forms exactly", () "computed Reflect.apply(require)() cannot be resolved statically at line 9", "computed require.call() cannot be resolved statically at line 10", "computed require.apply() cannot be resolved statically at line 11", + "computed escaped require() cannot be resolved statically at line 12", + "computed escaped require() cannot be resolved statically at line 13", "computed require.apply() cannot be resolved statically at line 16", + "computed escaped require() cannot be resolved statically at line 17", ]); } finally { fs.rmSync(rootDir, { recursive: true, force: true }); @@ -2452,7 +2457,8 @@ test("shadowed globals and var-scoped constants do not escape their lexical sema ].join("\n")); const warnings = []; assert.deepEqual(extractImports(context(rootDir), filePath, warnings).map((reference) => reference.specifier), ["./var-scoped.js"]); - assert.deepEqual(warnings, []); + // A shadowed Reflect is not a known forwarding operation; the builtin loader escapes. + assert.deepEqual(warnings.map((warning) => warning.details.line), [4]); } finally { fs.rmSync(rootDir, { recursive: true, force: true }); } @@ -3521,3 +3527,97 @@ test("module resolution declaration surface hash follows alias and package self fs.rmSync(rootDir, { recursive: true, force: true }); } }); + +test("bug #48 internal declarations preserve adjacent public signatures", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + manifest.cells[0].publicSymbols = ["api", "stable"]; + write("cellfence.manifest.json", manifest); + const source = (type, hidden = "true") => `export function api(value: ${type}): ${type} { return value; }\n/** @internal */\nexport const hidden = ${hidden};\nexport const stable = 1;\n`; + const filePath = write("src/producer/public.ts", source("string")); + const before = publicSurfaceHash(filePath); + write("cellfence.baseline.json", createBaseline({ rootDir })); + write("src/producer/public.ts", source("string", "'changed'")); + assert.equal(publicSurfaceHash(filePath), before); + write("src/producer/public.ts", source("number")); + assert.notEqual(publicSurfaceHash(filePath), before); + const checked = checkRepository({ rootDir, baselinePath: "cellfence.baseline.json" }); + assert.equal(checked.ok, false); + assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_RATCHET_PUBLIC_SURFACE_SIGNATURE_CHANGE")); + for (const separator of ["\n", " "]) { + write("src/producer/public.ts", `/** @internal */ export const hidden = 1;${separator}export const api = 1;${separator}/** @internal */ export const hidden2 = 2;${separator}export const stable = 1;`); + const first = publicSurfaceHash(filePath); + write("src/producer/public.ts", fs.readFileSync(filePath, "utf8").replace("api = 1", "api = 'changed'")); + assert.notEqual(publicSurfaceHash(filePath), first); + } + write("src/producer/public.ts", "export declare const api: string\n/** @internal */ const hidden = 1;export declare const stable: number;"); + assert.equal(declarationTextForRoot(filePath, declarationEmitCompilerOptions(filePath)), "export declare const api: string;\nexport declare const stable: number;\n"); +}); + +test("bug #49 createRequire origins agree with Node and unknown origins fail closed", (testContext) => { + const { rootDir, write } = bugFixture(testContext); + write("src/producer/private.cjs", "module.exports = 42;"); + write("src/consumer/private.cjs", "module.exports = 0;"); + const origin = path.join(rootDir, "src/producer/public.ts"); + const bases = [JSON.stringify(origin), JSON.stringify(pathToFileURL(origin).href), "new URL('../producer/public.ts', import.meta.url)", `new URL(${JSON.stringify(pathToFileURL(origin).href)})`]; + for (const base of bases) { + const filePath = write("src/consumer/load.mjs", `import { createRequire } from 'node:module'; const loader = createRequire(${base}); const alias = loader; console.log(alias('./private.cjs'));`); + const runtime = spawnSync(process.execPath, [filePath], { encoding: "utf8" }); + assert.equal(runtime.status, 0, runtime.stderr); + assert.equal(runtime.stdout.trim(), "42"); + const checked = checkRepository({ rootDir }); + assert.equal(checked.ok, false); + assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_PRIVATE_IMPORT" && finding.details.targetPath === "src/producer/private.cjs")); + } + for (const base of ["process.env.ORIGIN", "'relative/path.js'", "'https://example.invalid/a.js'", "new URL('./a.js', process.env.ORIGIN)", "'file://['", "new URL", "new URL(candidate)", "new URL(candidate, import.meta.url)", `new URL(${bases[1]}, process.env.ORIGIN)`, "new URL('https://example.invalid/a.js')", "import.meta.resolve"]) { + write("src/consumer/load.mjs", `import { createRequire } from 'node:module'; const loader = createRequire(${base}); loader('./private.cjs');`); + const checked = checkRepository({ rootDir }); + assert.equal(checked.ok, false, base); + assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE"), base); + } + write("src/consumer/load.mjs", "import { createRequire } from 'node:module'; function load() { const loader = createRequire(new.target.url); loader('./private.cjs'); }"); + assert(checkRepository({ rootDir }).findings.some((finding) => finding.ruleId === "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE")); + for (const invocation of ["loader.call(null, './private.cjs')", "loader.apply(null, ['./private.cjs'])", "Reflect.apply(loader, null, ['./private.cjs'])", `createRequire(${bases[2]})('./private.cjs')`, "const bound = loader.bind(null); bound('./private.cjs')"]) { + const filePath = write("src/consumer/load.mjs", `import { createRequire } from 'node:module'; const loader = createRequire(${bases[2]}); ${invocation};`); + const warnings = []; + const references = extractImports(context(rootDir), filePath, warnings); + assert(references.some((reference) => reference.resolutionBasePath === "src/producer/public.ts")); + } +}); + +test("bug #55 assigned or escaped require aliases are explicit unresolved analysis", (testContext) => { + const { rootDir, write } = bugFixture(testContext); + write("src/producer/private.cjs", "module.exports = 42;"); + const filePath = write("src/consumer/load.cjs", "let load; load = require; console.log(load('../producer/private.cjs'));"); + const runtime = spawnSync(process.execPath, [filePath], { encoding: "utf8" }); + assert.equal(runtime.status, 0, runtime.stderr); + assert.equal(runtime.stdout.trim(), "42"); + for (const source of ["let load; load = require; load('../producer/private.cjs');", "let load; if (flag) load = require; load('../producer/private.cjs');", "consume(require);", "const load = require; consume(load);"]) { + write("src/consumer/load.cjs", source); + const checked = checkRepository({ rootDir }); + assert.equal(checked.ok, false, source); + const finding = checked.findings.find((finding) => finding.ruleId === "CELLFENCE_UNSUPPORTED_DYNAMIC_REQUIRE"); + assert(finding); + assert.equal(finding.details.line, 1); + assert.match(finding.message, source.includes("consume") + ? /computed escaped require\(\) cannot be resolved statically at line 1/ + : /assigned require alias cannot be resolved statically at line 1/); + } + write("src/consumer/load.cjs", "function harmless(require) { let load; load = require; consume(load); }\n"); + assert.equal(checkRepository({ rootDir }).ok, true); +}); + +test("bug #54 Python package precedence agrees with importlib", (testContext) => { + const { rootDir, write } = bugFixture(testContext); + write("src/producer/service.py", "value = 0\n"); + write("src/producer/service/__init__.py", "value = 42\n"); + const filePath = write("src/consumer/load.py", "import producer.service\nprint(producer.service.__file__)\n"); + const runtime = spawnSync("python3", ["-I", "-c", "import sys; sys.path.insert(0, sys.argv[1]); import producer.service; print(producer.service.__file__)", path.join(rootDir, "src")], { encoding: "utf8" }); + assert.equal(runtime.status, 0, runtime.stderr); + assert.equal(resolvePythonImport(rootDir, "src/consumer/load.py", "producer.service", ["src"]), path.relative(rootDir, runtime.stdout.trim()).replace(/\\/g, "/")); + fs.unlinkSync(path.join(rootDir, "src/producer/service/__init__.py")); + assert.equal(resolvePythonImport(rootDir, "src/consumer/load.py", "producer.service", ["src"]), "src/producer/service.py"); + write("other/producer/service/__init__.py", "value = 99\n"); + assert.equal(resolvePythonImport(rootDir, "src/consumer/load.py", "producer.service", ["other", "src"]), "other/producer/service/__init__.py"); + assert.equal(resolvePythonImport(rootDir, "src/consumer/load.py", "producer.service", ["src", "other"]), "src/producer/service.py"); + assert.equal(fs.existsSync(filePath), true); +}); diff --git a/tests/official-plugins.test.mjs b/tests/official-plugins.test.mjs index f8a1443..c82fe26 100644 --- a/tests/official-plugins.test.mjs +++ b/tests/official-plugins.test.mjs @@ -982,7 +982,7 @@ test("official plugin path matchers agree with the minimatch dialect oracle", () }); assert.equal( blastRule.run(directContext(blastRepository)).length > 0, - expected, + expected || (!pattern.includes("*") && minimatch(relativePath, `${pattern}/**`, { dot: true })), `blast-radius pattern=${pattern} path=${relativePath}`, ); } @@ -2741,3 +2741,19 @@ test("economy matrix plugin exposes the reporter through plugin metadata", () => assert.equal(plugin.reporters.length, 1); assert.equal(plugin.reporters[0].name, "@cellfence/reporter-economy-matrix"); }); + +test("blast radius bug #57 bare ownership includes descendants and deleted paths", () => { + const rule = directRule(blastRadiusPlugin({ maxAffectedCells: 0 }), "blast-radius/affected-cells"); + for (const pattern of ["src/core", "src/core/", "src/core/**"]) { + const repository = baseRepository({ + manifest: { schemaVersion: "cellfence.manifest.v1", cells: [{ id: "core", ownedPaths: [pattern], publicEntry: "src/core/public.ts", publicSymbols: [] }] }, + changedFiles: new Set(["src/core/deleted.ts"]), + files: { ...baseRepository().files, byCell: {} }, + imports: [{ importerCellId: "app", targetCellId: "core" }], + }); + assert.deepEqual(rule.run(directContext(repository))[0].details.changedCells, ["core"]); + assert.deepEqual(rule.run(directContext(repository))[0].details.affectedCells, ["app"]); + repository.changedFiles = new Set(["src/core-extra/file.ts"]); + assert.deepEqual(rule.run(directContext(repository)), []); + } +}); diff --git a/tests/resource-access-coverage.test.mjs b/tests/resource-access-coverage.test.mjs index d99ef47..2edb5f4 100644 --- a/tests/resource-access-coverage.test.mjs +++ b/tests/resource-access-coverage.test.mjs @@ -2222,3 +2222,67 @@ test("resource access covers adapter-off branches and alternate framework shapes fs.rmSync(rootDir, { recursive: true, force: true }); } }); + +import { bugFixture } from "./bug-fixtures.mjs"; + +test("bug #50 HTTP URL resolution preserves the authority and base", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + manifest.cells[1].resourceContracts = [{ id: "http", kind: "http", access: ["call"], selectors: ["/health"] }]; + write("cellfence.manifest.json", manifest); + const inputs = [ + ["/health", "https://unapproved.example/base/"], + ["health", "https://unapproved.example/base/"], + ["//other.example/health", "https://unapproved.example/base/"], + ["https://other.example/health", "https://unapproved.example/base/"], + ["https://other.example/health", undefined], + ]; + for (const [input, base] of inputs) { + write("src/consumer/http.ts", `fetch(new URL(${JSON.stringify(input)}${base ? `, ${JSON.stringify(base)}` : ""}));`); + const checked = checkRepository({ rootDir }); + assert.equal(checked.ok, false); + assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_UNDECLARED_RESOURCE_ACCESS" && finding.details.selector === new URL(input, base).href)); + } + for (const expression of ["new URL('/health', unknownBase)", "new URL('/health')", "new URL('/health', 'broken')", "new URL()", "new URL"] ) { + write("src/consumer/http.ts", `fetch(${expression});`); + assert(checkRepository({ rootDir }).findings.some((finding) => finding.ruleId === "CELLFENCE_UNRESOLVED_RESOURCE_ACCESS")); + } + manifest.cells[1].resourceContracts[0].selectors = ["https://allowed.example/health"]; + write("cellfence.manifest.json", manifest); + write("src/consumer/http.ts", "const endpoint = flag ? '/health' : 'http://['; fetch(new URL(endpoint, 'https://allowed.example'));"); + assert(checkRepository({ rootDir }).findings.some((finding) => finding.ruleId === "CELLFENCE_UNRESOLVED_RESOURCE_ACCESS")); +}); + +test("bug #51 resource candidate limits fail closed without losing alternatives", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + const urls = Array.from({ length: 17 }, (_, index) => `https://host${index}.example`); + const conditional = (values, choice = "pick") => values.slice(0, -1).map((value, index) => `${choice} === ${index} ? ${JSON.stringify(value)} : `).join("") + JSON.stringify(values.at(-1)); + manifest.cells[1].resourceContracts = [{ id: "http", kind: "http", access: ["call"], selectors: urls.slice(0, 16) }]; + write("cellfence.manifest.json", manifest); + for (const local of [false, true]) { + for (const size of [16, 17]) { + write("src/consumer/http.ts", `${local ? "export function run(pick) {" : "declare const pick: number;"} const endpoint = ${conditional(urls.slice(0, size))}; fetch(endpoint);${local ? "}" : ""}`); + const checked = checkRepository({ rootDir }); + assert.equal(checked.ok, size === 16, JSON.stringify(checked.findings)); + if (size === 17) assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_UNRESOLVED_RESOURCE_ACCESS")); + } + write("src/consumer/http.ts", `${local ? "function run(pick) {" : ""}const endpoint = ${conditional([...urls.slice(0, 16), urls[0]])}; fetch(endpoint);${local ? "}" : ""}`); + assert.equal(checkRepository({ rootDir }).ok, true); + write("src/consumer/http.ts", `${local ? "function run(pick) {" : ""}const prefix = ${conditional(urls.slice(0, 4))}; const suffix = ${conditional(["/a", "/b", "/c", "/d", "/e"])}; const endpoint = prefix + suffix; fetch(endpoint);${local ? "}" : ""}`); + assert(checkRepository({ rootDir }).findings.some((finding) => finding.ruleId === "CELLFENCE_UNRESOLVED_RESOURCE_ACCESS")); + } + write("src/consumer/http.ts", `const endpoint = ${conditional(["/a", "/b", "/c", "/d"], "pathChoice")}; const origin = ${conditional(urls.slice(0, 5), "hostChoice")}; fetch(new URL(endpoint, origin));`); + assert(checkRepository({ rootDir }).findings.some((finding) => finding.ruleId === "CELLFENCE_UNRESOLVED_RESOURCE_ACCESS")); +}); + +test("bug #52 supported Fastify route methods do not depend on scan hints", (testContext) => { + const { rootDir, write } = bugFixture(testContext); + for (const method of ["get", "post", "put", "patch", "delete", "options", "head", "all"]) { + const source = `import Fastify from 'fastify'; const app = Fastify(); app.${method}('/private', async () => ({}));`; + for (const comment of ["", "// fetch\n", "/* ordinary comment */\n\n"]) { + write("src/consumer/routes.ts", comment + source); + const checked = checkRepository({ rootDir }); + assert.equal(checked.ok, false, method); + assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_UNDECLARED_RESOURCE_ACCESS" && finding.details.selector.endsWith(" /private")), method); + } + } +}); diff --git a/tests/review-regressions.test.mjs b/tests/review-regressions.test.mjs index 7b128b7..9cfef09 100644 --- a/tests/review-regressions.test.mjs +++ b/tests/review-regressions.test.mjs @@ -464,3 +464,100 @@ test("review CF-20, CF-21, CF-23 through CF-25: governance metadata and recovery fs.rmSync(pythonRoot, { recursive: true, force: true }); } }); + +import { bugFixture } from "./bug-fixtures.mjs"; +import { checkChangedRepository, createBaseline, createClaim, checkClaims, checkWriteAccess } from "../packages/engine/dist/index.js"; +import { walkCoverage } from "../packages/cli/dist/coverage-walker.js"; + +test("bug #53 explicit headRef checks that snapshot and leaves caller dirt untouched", (testContext) => { + const { rootDir, write } = bugFixture(testContext); + initGit(rootDir); + git(rootDir, ["add", "."]); git(rootDir, ["commit", "-qm", "base"]); + const baseRef = git(rootDir, ["rev-parse", "HEAD"]); + write("src/consumer/work.ts", "import { secret } from '../producer/private'; console.log(secret);\n"); + git(rootDir, ["add", "."]); git(rootDir, ["commit", "-qm", "private import"]); + const headRef = git(rootDir, ["rev-parse", "HEAD"]); + git(rootDir, ["checkout", "--detach", baseRef]); + write("README.md", "uncommitted user content\n"); + const before = git(rootDir, ["status", "--porcelain"]); + const checked = checkChangedRepository({ rootDir, baseRef, headRef, manifestPath: path.join(rootDir, "cellfence.manifest.json") }); + assert.equal(checked.ok, false); + assert(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_PRIVATE_IMPORT")); + assert.equal(git(rootDir, ["rev-parse", "HEAD"]), baseRef); + assert.equal(git(rootDir, ["status", "--porcelain"]), before); + assert.equal(git(rootDir, ["worktree", "list", "--porcelain"]).split("worktree ").length, 2); + assert.equal(checkChangedRepository({ rootDir, baseRef, headRef: baseRef }).ok, true); +}); + +test("bug #56 cell reservations conflict with descendant globs for either ownership spelling", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + manifest.cells[0].ownedPaths = ["src/producer/"]; + write("cellfence.manifest.json", manifest); + assert.equal(createClaim({ rootDir, agent: "one", cells: ["producer"], ttl: "1h" }).ok, true); + const conflict = createClaim({ rootDir, agent: "two", paths: ["src/producer/*.ts"], ttl: "1h" }); + assert.equal(conflict.ok, false); + assert(conflict.findings.some((finding) => finding.ruleId === "CELLFENCE_ACTIVE_CLAIM_CONFLICT")); + assert.equal(checkClaims({ rootDir }).ok, true); + assert.equal(checkWriteAccess({ rootDir, agent: "one", paths: ["src/producer/private.ts"] }).ok, true); + assert.equal(checkWriteAccess({ rootDir, agent: "two", paths: ["src/producer/private.ts"] }).ok, false); + assert.equal(createClaim({ rootDir, agent: "two", paths: ["src/producer-extra/*.ts"], ttl: "1h" }).ok, true); +}); + +test("bug #58 coverage uses governance exclusions and preserves unresolved location", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + manifest.governance.exclude = ["src/consumer/hidden.ts"]; + write("cellfence.manifest.json", manifest); + write("src/consumer/hidden.ts", "this is invalid {{{\n"); + const clean = walkCoverage({ rootDir }); + assert.equal(clean.check.ok, true); + assert.equal(clean.totalFiles, 4); + assert.equal(clean.analyzedFiles.length, 4); + assert(!clean.analyzedFiles.includes("src/consumer/hidden.ts")); + write("src/consumer/dynamic.ts", "// location\nrequire(candidate);\n"); + const unresolved = walkCoverage({ rootDir }); + assert.equal(unresolved.totalFiles, 5); + assert.equal(unresolved.analyzedFiles.length, 4); + assert(unresolved.unresolved.some((entry) => entry.line === 2 && entry.cellId === "consumer")); +}); + +test("bug #59 documented bootstrap requires approval before baseline creation", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + write("src/consumer/resource.ts", "import fs from 'node:fs'; fs.readFileSync('data/a.txt');\n"); + assert.throws(() => createBaseline({ rootDir }), /undeclared file resource data\/a.txt/); + manifest.cells[1].resourceContracts = [{ id: "data", kind: "file", access: ["read"], selectors: ["data/a.txt"] }]; + write("cellfence.manifest.json", manifest); + write("cellfence.baseline.json", createBaseline({ rootDir })); + assert.equal(checkRepository({ rootDir, baselinePath: "cellfence.baseline.json" }).ok, true); +}); + +test("bug #60 Python runtime stdlib creates no dependency ratchet delta", (testContext) => { + const { rootDir, write } = bugFixture(testContext); + write("cellfence.baseline.json", createBaseline({ rootDir })); + write("src/consumer/imports.py", "import json\nimport types\nimport hashlib\nimport xml.etree.ElementTree\n"); + const checked = checkRepository({ rootDir, baselinePath: "cellfence.baseline.json" }); + assert.equal(checked.ok, true, JSON.stringify(checked.findings)); + assert.deepEqual(checked.metrics.consumer.externalDependencySet, []); + write("src/consumer/imports.py", "import third_party_example\n"); + assert.equal(checkRepository({ rootDir, baselinePath: "cellfence.baseline.json" }).ok, false); + write("src/consumer/imports.py", "import types\n"); + write("src/types/__init__.py", "value = 1\n"); + assert(checkRepository({ rootDir }).findings.some((finding) => finding.ruleId === "CELLFENCE_UNOWNED_SOURCE")); +}); + +test("bug #61 empty Changed-Cells is checked against actual ownership", (testContext) => { + const { rootDir, write, manifest } = bugFixture(testContext); + manifest.cells[0].ownedPaths = ["src/producer"]; + write("cellfence.manifest.json", manifest); + initGit(rootDir); git(rootDir, ["add", "."]); git(rootDir, ["commit", "-qm", "base"]); + const message = (declared) => `Update implementation\n\nProblem:\nA concrete behavior needs adjustment.\nChange:\nThe implementation changes the returned value.\nBehavior:\nThe returned number is forty three.\nTests:\nManual inspection checks this constant change.\nKnown-Gaps:\nNo further assumptions in this fixture.\n\nChange-Type: implementation\nChanged-Cells: ${declared}\nTests-Added: none\nTests-Modified: none\nTest-Impact: Existing behavior is checked by manual inspection.\nTests-Not-Added-Reason: This fixture modifies only a returned constant.\nAgent-Run-Id: regression-run\nAgent-Task-Id: regression-task\n`; + for (const declared of ["none", "n/a", "producer"]) { + write("src/producer/private.ts", `export const secret = ${JSON.stringify(declared)};\n`); + git(rootDir, ["add", "."]); git(rootDir, ["commit", "-qm", message(declared)]); + const checked = checkCommitEvidence({ rootDir, manifest, commit: "HEAD" }); + assert.deepEqual(checked.commits[0].changedCells, ["producer"]); + assert.equal(checked.findings.some((finding) => finding.ruleId === "CELLFENCE_COMMIT_CHANGED_CELLS_MISMATCH"), declared !== "producer"); + } + write("README.md", "Documentation only.\n"); + git(rootDir, ["add", "."]); git(rootDir, ["commit", "-qm", message("none")]); + assert.equal(checkCommitEvidence({ rootDir, manifest, commit: "HEAD" }).ok, true); +});