@devmedic/ignore-engine
v0.1.0
Published
Merges every ignore source a project has (.devmedicignore, .gitignore, workspace defaults, plugin declarations, CLI flags) into one gitignore-correct matcher, and scans a directory tree pruning ignored directories before descending into them — ignore matc
Downloads
30
Readme
@devmedic/ignore-engine
Merges every ignore source a project can have — .devmedicignore,
.gitignore, workspace defaults, plugin declarations, a --ignore CLI
flag — into one real, gitignore-correct matcher (**, *, ?, [...]
character classes, ! negation, directory-only trailing-/ patterns),
and scans a directory tree pruning an ignored directory before ever
reading its contents. This is the one ignore engine every scanner should
use, replacing each package's own separate, inevitably-inconsistent list.
import { createIgnoreService } from '@devmedic/ignore-engine';
const ignoreService = await createIgnoreService({
projectRoot: '/path/to/project',
workspaceIgnore: ['**/coverage/**'], // @devmedic/config's DEFAULT_IGNORE + config.ignore
pluginIgnore: ['__tests__', 'fixtures'], // aggregated from every loaded plugin's manifest
cliIgnore: ['!keep-this-one.log'], // a --ignore flag — applied last, can override everything above
});
ignoreService.isIgnored('/path/to/project/src/App.ts'); // false
ignoreService.isIgnored('/path/to/project/node_modules', true); // true — isDirectory matters for trailing-/ patterns
const files = await ignoreService.scan(); // readonly string[], absolute paths, sorted.gitignore/.devmedicignore are read straight from projectRoot (pass
useIgnoreFiles: false to skip that, e.g. in tests). ignoreService.sources
lists every source that was merged, even ones with no patterns, in the
order they were applied — useful for a devmedic doctor-style diagnostic
of "why was this file ignored."
Merge order
Later sources can !re-include a path an earlier one excluded — real
gitignore precedence, applied across the whole merged set, not per-source:
- workspace —
@devmedic/config'sDEFAULT_IGNORE+ a project's ownconfig.ignore. - gitignore —
.gitignore, if present. - devmedicignore —
.devmedicignore, if present — DevMedic-specific, so it can override.gitignore(e.g. un-ignore something git ignores but DevMedic still wants to see). - plugin — aggregated from every loaded plugin's manifest-declared
ignoredDirectories/ignoredFiles/ignoredPatterns. - cli — a
--ignoreflag — the most specific, most immediate source, applied last so it has the final say.
Pattern syntax
Real .gitignore semantics, via the ignore package rather than a hand-rolled matcher — negation-under-an-excluded-directory and anchoring rules are exactly the kind of thing worth not reinventing:
| Syntax | Meaning |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| * | Any characters within one path segment (*.log matches debug.log, not src/debug.log unless the pattern itself has no leading /) |
| ** | Any number of directory levels (**/generated/** matches at any depth) |
| ? | Exactly one character |
| [abc]/[a-z] | A character set or range |
| [!abc] | A negated character set |
| !pattern | Re-includes a path an earlier pattern excluded |
| dir/ | Directory-only (matches only when isIgnored(path, true) is called) |
| No leading / | Matches at any depth — node_modules matches both node_modules/ and packages/foo/node_modules/ |
A real git limitation, faithfully preserved: !negation can re-include
a file an earlier file pattern excluded (*.log then !keep.log), but
cannot resurrect a file whose parent directory was excluded (scratch
then !scratch/notes.ts does nothing — notes.ts stays excluded). This is
git's own documented behavior, not a bug in this package; if you need to
keep one file, target the file pattern directly rather than the containing
directory.
Performance: ignore matching before scanning
scanDirectory never calls readdir on an ignored directory — it prunes
before descending, so a huge ignored directory (node_modules, a build
output folder) costs one directory-entry check, never a walk of its
contents. src/scan.bench.ts (pnpm bench) quantifies this against a
naive "list everything, then filter" walker on the same tree (20 relevant
files + a 10,000-file ignored node_modules):
| Benchmark | Result |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| scanDirectory: 1,000 vs. 10,000 ignored files | No meaningful difference — both sub-millisecond, confirming the walk doesn't scale with ignored-directory size |
| scanDirectory (pruned) vs. naive scan-then-filter, same tree | ~3,600x faster — the naive walker still reads every one of the 10,000 ignored files' directory entries |
Numbers are from one real run and will vary by machine — re-run locally
(pnpm --filter @devmedic/ignore-engine run bench) for your own baseline.
Used by every scanner
apps/cli's workspace file discovery (discoverWorkspaceFiles) — the primary integration: whatIgnoreService.scan()returns is exactly what@devmedic/rule-engine#RuleEngine.analyze()receives.@devmedic/project-detection-engine's gate —createProjectDetectionGate({ isIgnored })acceptsIgnoreService.isIgnoredas an additional check, alongside (not replacing) that package's own simpler built-in ignore list.@devmedic/project-scanner's language-file counting is deliberately left as-is — it already has its own small, correct, narrow-purpose ignore list for afast-glob-based scan; unifying it would mean converting gitignore-style bare names (tests, matches any depth) intofast-glob's different pattern dialect (**/tests/**), which is easy to get subtly wrong for arbitrary caller-supplied patterns. Not attempted — a deliberate scope decision, not an oversight.
Depends on
ignore— real, well-established gitignore-semantics matching.
