Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ref>` 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:
Expand Down
4 changes: 3 additions & 1 deletion docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 6 additions & 6 deletions docs/ratchets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 24 additions & 15 deletions packages/cli/src/coverage-walker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,36 +88,45 @@ 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<string>();
const cellByPath = new Map<string, string>();
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 {
// The check result already carries the configuration error. Keep coverage
// 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))
Expand Down
6 changes: 3 additions & 3 deletions packages/engine/src/advanced-governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -638,7 +638,7 @@ function owningCellsForFiles(manifest: CellFenceManifest, files: string[]): stri
const cells = new Set<string>();
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));
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions packages/engine/src/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`);
}
}
Expand Down
66 changes: 2 additions & 64 deletions packages/engine/src/external-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,69 +15,6 @@ const NODE_BUILTINS = new Set<string>([
...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;
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 27 additions & 4 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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",
);
Expand All @@ -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 });

Expand Down Expand Up @@ -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));
Expand Down
Loading