@conmoong/graph-validate
v0.1.0
Published
Workspace-level checker for @conmoong/graph's module-graph facts: cycles, phantom/dev-only dependencies, unused files and packages, and import boundary rules, combined across a tsconfig "references" tree or an explicit set of .graph.json files.
Maintainers
Readme
@conmoong/graph-validate
Workspace-level checker for @conmoong/graph's
module-graph facts. @conmoong/graph (tsc-p's compiled-in emit plugin)
only ever records facts — it never judges them once "emit" is
configured. This package is the separate tool that reads those facts and
applies rules, either for one project or combined across a tsconfig
"references" tree.
The .graph.json fact schema
This is the file @conmoong/graph writes when its "emit" option is
configured (default name <tsconfig-basename>.graph.json, next to the
tsconfig). It is facts only — no rule configuration, no severities, no
pass/fail judgements — so it doubles as a general-purpose resource (e.g. a
future bundler tree-shaking input), not just this tool's own input.
{
// The nearest package.json's own "name" field, if any. "" if none was found.
"package": "my-package",
// The absolute path this file was written to (informational).
"tsconfig": "/abs/path/to/tsconfig.graph.json",
// Every source file this compilation processed, relative to the
// tsconfig's own directory, always "./"- or "../"-prefixed.
"files": ["./src/index.ts", "./src/helpers.ts"],
// Every named/default export, one entry per (file, name).
"exports": [
{ "file": "./src/helpers.ts", "name": "helper" }
],
// Every import/export-from/require()/dynamic-import() reference.
"edges": [
{
"from": "./src/index.ts",
// Present only for an "internal" edge: the resolved project file.
"to": "./src/helpers.ts",
// The specifier exactly as written in source.
"specifier": "./helpers",
// "internal" (resolves to another file in this compilation) or
// "external" (resolves to node_modules, a workspace sibling
// package, or could not be resolved at all).
"kind": "internal",
// Present only for an "external" edge that DID resolve: the
// resolver's own resolved package identity (module.ResolvedModule.PackageId.Name),
// NOT the raw specifier text — "@babel/core/lib/foo" and
// "@babel/core" both record resolvedPackage "@babel/core".
"resolvedPackage": "",
// The specific named bindings reached, if any were written
// (e.g. `import { helper }`) — absent for a bare/default/
// namespace-only import or an unnamed require()/dynamic import().
"names": ["helper"],
// `import def from "x"` / `const def = require("x")` (whole binding, no destructure).
"isDefault": false,
// `import * as ns from "x"` — conservatively treated elsewhere
// as "could reach anything x exports".
"isNamespace": false
}
]
}Known v1 simplifications (see internal/tscp/graph/transformer.go's own
comments for the authoritative list): destructuring export patterns
(export const { a, b } = obj) and export = expr are not modelled as
named exports; a require()/dynamic import() never records names
even when the caller immediately destructures its result.
The @conmoong/graph plugin config (for context)
Rules live in the SAME tsconfig @conmoong/graph plugin entry that
controls "emit" — they can be present together; "emit" always wins
for evaluation (this tool is what applies "rules" in that case), and
tsc-p itself only evaluates "rules" inline when "emit" is not set
(see the root README's @conmoong/graph section for the full plugin
design and inline-evaluation behaviour):
{
"compilerOptions": {
"plugins": [{
"name": "@conmoong/graph",
"emit": true,
"rules": [
{ "module": "some_npm_library", "phantomImport": "error" },
{ "module": "./*", "cycle": "error" },
{ "module": "./src/*", "devLeak": "error" },
{ "module": "./src/features/*", "unused": "error" },
{ "module": "./src/features/*", "importRules": [
{ "module": "./src/other-features/*", "severity": "error" }
] }
]
}]
}
}A "module" pattern uses the same single-* wildcard syntax as
tsconfig "paths". A pattern starting with "./" or "../" is a
file pattern; anything else (a bare name or scoped package name, e.g.
"@babel/*") is a resolved package name pattern — this exact
distinction is also how graph-validate decides whether a sub-project's
rule needs re-scoping when merged into a references tree (see below).
The most-specific matching pattern wins (an exact pattern always beats a
* pattern; among * patterns, the longer prefix wins); entries sharing
the exact same pattern text merge, with a later entry's fields
overriding an earlier one's.
Checks: cycle (import cycle), phantomImport (a resolved package used
but not declared in package.json dependencies), devLeak (used at
runtime but only declared in devDependencies — complementary with
phantomImport, not mutually exclusive), unused (this file, or this
declared dependency, is never imported anywhere in the graph — a file's
own entry point should be given an explicit "allow" override, since
nothing internal ever imports it), importRules (a boundary/layering
list on an entry's own pattern: {"module": pattern, "severity"},
default "allow" — nothing is denied unless listed, and a target can be
either a file or an external package).
CLI usage
npx graph-validate -p tsconfig.json # walk "references" transitively
npx graph-validate -c graph-validate.json # an explicit file list instead
npx graph-validate # tries ./graph-validate.json, then ./tsconfig.json-p <tsconfig>: walking a references tree
Starting from the given tsconfig, references is walked transitively
(needed for multi-hop cross-package cycles, not just direct references).
For each tsconfig visited (the root included):
- If its
@conmoong/graphplugin entry has"emit"configured, the resulting.graph.jsonpath is required — missing is a hard error ("tsc-p was not run properly, run it first"), since the project explicitly promised a facts file. - Otherwise, the conventional
<tsconfig-basename>.graph.jsonpath is tried opportunistically (tolerating a manually- or externally-produced file); missing there is only a warning, and that one project is skipped — not fatal to the rest of the run.
Rule combination: the effective rule set is the combination of every
visited project's own rules (from its own @conmoong/graph plugin
entry) plus the root tsconfig's own rules — not the root's alone. A
sub-project's own file-pattern rules are first re-scoped into a shared,
root-relative namespace (a rule written as "./src/*" inside
packages/foo/tsconfig.json is merged as "./packages/foo/src/*"), so
it can never accidentally match a sibling package's files. On an exact
pattern-text collision after re-scoping, the sub-project's own rule
wins over the root's (the root's rules act as workspace-wide defaults a
project can override). Package-name patterns (not starting with "./"
or "../") are never re-scoped.
Cross-package cycles are detected even though a fact only records "this
file imports resolved package X", never which specific file inside X —
graph-validate recognises when an external edge's resolvedPackage
matches another loaded project's own "package" field and treats that
whole package as one traversable unit for cycle purposes. phantomImport/
devLeak/unused(dependency)/importRules remain evaluated per project,
against that project's own package.json. A file is never flagged
unused if some other loaded project's graph imports its whole package —
facts don't record which specific file satisfies a cross-package import,
so the whole package is conservatively treated as possibly used.
-c <config.json>
{
// Glob(s) matching .graph.json files directly (NOT tsconfig files),
// relative to this config's own directory — behaves like a
// tsconfig's own "include" field.
"included": ["packages/*/tsconfig.graph.json"],
"rules": [ /* same schema as a plugin entry's "rules" */ ]
}No references walking or per-project rules combination happens in
this mode — rules here is the only rule set applied, uniformly, to
whatever .graph.json files match included. Each matched project's own
directory (where its .graph.json lives) is still used to look up its
nearest package.json for phantomImport/devLeak/unused.
Known v1 limitations
extends is not followed when reading a tsconfig — "plugins" and
"references" are read directly off each tsconfig as written. JSONC
comments/trailing commas are stripped with a pragmatic string pass, not a
full parser.
