@m9ter1a/freshclone
v0.2.1
Published
Will this repo build on someone else's clean machine? Audit a project for hidden dependencies on your local environment.
Maintainers
Readme
freshclone
freshclone finds the things in your repository that only work because of how your machine is set up. A script that calls a globally installed binary. An import whose case is wrong in a way only Linux notices. A file the build needs that .gitignore keeps out of the clone. One command, one score, one list of what to fix.
Install
npx @m9ter1a/freshclone ./Nothing to install for a one-off run. To keep it in a project:
npm i -D @m9ter1a/freshcloneNode 18 or newer. One dependency.
What it looks like
freshclone my-app
Grade C 70/100
Dependencies ████████░░ 75
Portability ████████░░ 75
Reproducibility █████░░░░░ 50
Environment ████████░░ 84
4 errors · 2 warnings · 1 info
ERR `./Renderer.js` resolves to `src/renderer.js`, which is spelled differently src/index.js:1
The import asks for `src/Renderer.js` but the file on disk is `src/renderer.js`. Your
filesystem ignores the difference; ext4 on a Linux CI runner does not, and the build
fails there with a module-not-found error.
fix: Rename the file or fix the specifier so both use the same case.
[path-case-mismatch]
ERR `tsc` is called by scripts.build, but no dependency provides it package.json:6
None of the declared dependencies ship a binary by that name, so after a clean
`npm ci` the command is simply not on PATH. It most likely works for you because it
is installed globally.
fix: Add it to devDependencies (`npm i -D typescript`).
[bin-not-declared]
ERR `src/config.local.js` is imported by `src/index.js` but git ignores it src/index.js:2
The file exists on your machine and never reaches the repository, so a fresh clone
cannot compile this import at all.
[gitignored-build-input]
...knip checks your code. freshclone checks your environment.
knip and depcheck are import-graph analysers. They are very good at "you import a package that isn't in package.json", and freshclone does not go anywhere near that. It looks for the failures that an import graph cannot see even in principle:
| Problem | knip | freshclone |
| --- | :---: | :---: |
| Undeclared JS import | ✅ | — not our job |
| A script calls a binary no dependency ships | ❌ | ✅ |
| import './Foo' when the file is foo.ts | ❌ | ✅ |
| The build reads a file that is in .gitignore | ❌ | ✅ |
| The lockfile has drifted, so npm ci will fail | ❌ | ✅ |
| Code reads process.env.X that is documented nowhere | ❌ | ✅ |
Use both. They do not compete.
Usage
freshclone # audit the current directory
freshclone ./path/to/repo
freshclone --json # machine-readable report
freshclone --markdown # for a PR comment
freshclone --min-score 80 # gate a CI job
freshclone --only portability # one category
freshclone --ignore-rule bin-not-declaredExit codes — 0 clean · 1 an error-level finding, or the score is under --min-score · 2 freshclone could not run (no package.json, bad arguments). That makes it a CI gate out of the box:
- run: npx @m9ter1a/freshclone . --min-score 80What you can point it at
Anything with a package.json at its root. Without one it exits 2 and tells you so, rather than guessing.
It earns its keep on:
| | |
| --- | --- |
| Libraries and CLIs you publish | The core case. Your node_modules has been there for months; a stranger's has not. |
| Monorepos | Workspace members declared in workspaces or pnpm-workspace.yaml are audited individually: each member's own scripts and dependencies, with root hoisting and workspace:* siblings taken into account. Measured on vite — 294 members, 1697 source files, one pass. |
| Apps built with Vite, Vue, Svelte, Astro or plain HTML | Script regions inside markup are read, so a mis-cased <script src> or a broken import in a <script setup> block is caught like any other. |
| Anything with a CI pipeline | Exit codes and --min-score make it a gate; --markdown makes it a PR comment. |
It is thinner on:
- pnpm, yarn and bun repositories. Everything works, but lockfile verification is by package name only; the deep range check is npm-only. The report labels it
skippedinstead of implying a check happened. - Repositories without git available. The
.gitignorerule reports itself skipped rather than passing.
It is the wrong tool for: Python, Go, Rust or any non-Node ecosystem; finding unused or undeclared imports (that is knip); vulnerability scanning (npm audit); and checking whether your package is ready to publish — exports, files, tarball contents — which is a different job for a different tool.
Scale. The largest repository tested is svelte at 8137 source files; a full audit is a single pass with no network access and no install step, so it is fast enough to run on every commit.
What it checks
| Rule | Severity | What it means |
| --- | --- | --- |
| path-case-mismatch | error | An import differs from the real filename only in case. Fine on Windows and macOS, fatal on Linux CI. |
| unresolved-relative-import | error | A relative import points at nothing in the repository. |
| bin-not-declared | error | A script runs a command that no declared dependency provides — it is probably installed globally on your machine. |
| system-bin-required | warn | A script needs a system tool (make, python, deno) that npm cannot install. |
| npx-on-the-fly | info | A script downloads a package at run time, so the build needs network and is not reproducible. |
| gitignored-build-input | error | Something the build reads is excluded by .gitignore and will not survive a clone. |
| lockfile-missing | error | No lockfile is committed, so npm ci cannot run. |
| lockfile-out-of-sync | error | package.json and the lockfile disagree; a clean install refuses to reconcile them. |
| lockfile-multiple | warn | Two package managers' lockfiles are committed. |
| lockfile-manager-mismatch | warn | packageManager names a manager with no matching lockfile. |
| undocumented-env-var | warn | Code reads an environment variable that no .env.example or README mentions. |
| missing-env-example | warn | .env is ignored and nothing documents what belongs in it. |
| engines-node-missing / node-version-unpinned | info | Nothing states which Node version the project needs. |
| engines-node-unsatisfied | warn | Your local Node is outside the range the project declares, so the supported range is untested. |
Scoring: four categories, each 0–100, combined with weights (portability and dependencies count most). A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, F below. Repeats of the same rule stop counting after a few, so one systemic mistake cannot zero a category.
--only narrows the audit rather than filtering the output: the categories you did not ask for are dropped from the report entirely and the weights are renormalised, so an unaudited category never contributes a free 100.
What the report admits it did not read
A clean score over a repository freshclone barely opened is not good news, so every run prints what it skipped:
0 errors · 0 warnings · 0 info
184 of 224 source files analysed · 301 files in the repo
7 nested packages folded in as part of this project
Not analysed:
· 1 nested package (demo/) — separate projects, audit each on its ownIf the analysed share drops below 60%, the grade itself is prefixed with a Partial audit warning rather than a footnote. The same numbers are in --json under coverage, with a partial boolean.
What counts as part of your project. A nested package.json is classified, not assumed foreign. Workspace members named by workspaces or pnpm-workspace.yaml are yours. So is a subpath stub — a package.json with no scripts, dependencies or lockfile, the kind that exists so import "preact/hooks" resolves. Everything else (examples/, benchmark harnesses, e2e projects) is somebody else's audit and is reported as skipped.
Markup. Script regions of .vue, .svelte, .astro and .html are read: the template, styles and prose around them are blanked out first, so markup text is never mistaken for code and line numbers stay exact. A <script src> is case-checked but never called missing — a page routinely points at a bundle the build has yet to produce.
Remaining blind spots: .mdx (imports sit in prose beside fenced code samples), and anything past the file-walk cap.
Silencing what you meant to write
Some repositories contain broken code on purpose. A compiler's test suite has a directory named missing-file; a resolver's fixtures import ./irrelevant. Those findings are true and unwanted, so they are suppressed by path rather than argued away:
{
"freshclone": {
"exclude": ["packages/svelte/tests", "playground"],
"ignoreRules": ["npx-on-the-fly"]
}
}A pattern with no wildcard means the directory and everything under it. * matches inside one path segment, ** across segments. The same thing ad hoc: freshclone --exclude playground --ignore-rule npx-on-the-fly.
Two things about this are deliberate.
Exclusion filters findings, never the scan. Excluded files are still read and still resolve, so an import pointing into an excluded directory keeps working. Dropping those files instead would turn every such import into a phantom unresolved-relative-import — the suppression would manufacture the errors it was meant to silence.
The count stays visible. Every run prints 37 findings suppressed: 37 in excluded paths, and --json carries the same numbers. A repository that excluded its way to an A says so out loud. A malformed config is reported too, rather than quietly doing nothing.
The design rule: silence beats guessing
What a clean run does and does not mean. The two directions are not symmetric. A finding is close to certain: if freshclone says a script calls a binary nothing declares, that command really is missing after npm ci. A clean run is weaker evidence — it means no rule found anything to report, not that the project is portable. Static analysis cannot see a missing system library, a native module that fails to compile, or anything that only shows up when the build actually runs. That is the honest ceiling of this approach; running the build in a clean container (--deep) is what would raise it.
A linter that cries wolf gets uninstalled after one run. So freshclone reports nothing whenever it cannot be sure:
- An import written through a
tsconfigalias is case-checked, but an alias that resolves to nothing is silence, not a finding:tscalready reports that, and readingpathsis an approximation of the real module resolver. Bare package specifiers are skipped entirely. - A missing
dist/…orgenerated/…target is not a finding — the build produces those. - A nested
package.jsonmarks somebody else's project.examples/,e2e/and test fixtures are not audited as part of yours. - Semver ranges it cannot model exactly (hyphen ranges, prereleases,
workspace:) produce no verdict at all. - pnpm and yarn lockfiles are checked by package name only, and the report says so out loud rather than implying a deep check happened.
- Without
giton PATH, the.gitignorerule reports itself as skipped instead of passing.
The suite runs on Linux, macOS and Windows against Node 20 and 22, and path-case-mismatch is asserted on all of them — a rule about case sensitivity that is only ever tested on a case-insensitive filesystem proves nothing. So is gitignored-build-input, which shells out to git check-ignore and therefore depends on the platform's git.
Verified against eleven real repositories — axios, chalk, clsx, defu, express, hono, p-limit, preact, svelte, vite and zod — at 100% source coverage on six of them and 82–90% on the rest.
svelte and vite score D and C out of the box because their compiler fixtures are full of deliberately broken imports. Excluding those directories — two --exclude flags each — takes svelte to B 87 and vite to A 95, and what remains is real: svelte's check:tsgo script calls a binary no package in the repository declares.
Not in v1
No Docker, no container builds (that is --deep, later). No CVE scanning — that is npm audit. No publish-readiness audit (exports, files, tarball size). No non-JS ecosystems. And, again, no import-graph analysis: use knip.
API
The CLI is a thin wrapper. Everything is available as a library:
import { audit } from "@m9ter1a/freshclone";
const report = audit("./my-repo", { only: ["portability"] });
console.log(report.grade, report.score, report.findings);Every rule is a pure function of a ProjectContext; all filesystem and git access happens once, in scan().
License
MIT
