dowsing.json schema
dowsing analyze writes its result to .dowsing/dowsing.json. The CLI display, the HTML report, and the CI integration all consume that single file — so you can get several outputs without re-analysing.
The schema is defined with zod and self-validated on every write: the output is the schema.
No compatibility guarantee while schemaVersion is 0
Fields may be added or changed without notice. Be careful about building automation on it until it stabilises.
Overall shape
{
schemaVersion: 0,
meta: { ... }, // generation time, git window, exclusion counts
workspace: { ... }, // package list and detected tooling
files: [ ... ], // descending hotspot score
packages: [ ... ], // descending hotspotSum
packageGraph: { ... }, // declared dependencies + actual imports
coupling: { ... }, // change coupling (null without git history)
findings: [ ... ], // synthesised insights, descending severity
authors: [ ... ], // contributors, after mailmap and bot removal
config: { ... } // the formulas, thresholds, and weights used
}meta
{
generatedAt: string, // ISO 8601
repoRoot: string,
toolVersion: string,
git: {
since: string,
until: string | null,
commitCount: number, // after exclusions
shallow: boolean, // if true, do not trust the result
exclusions: {
merges: number,
bots: number,
formatOnly: number,
ignoreRevs: number,
largeChangesets: number // excluded from coupling only, kept in churn
}
} | null // null when there is no git history
}Exclusion counts are always included because a number whose scope you cannot see is not a number you can act on.
files[]
{
path: string, // repo-root relative, posix
package: string | null,
lines: number,
loc: number, // effective lines, comments and blanks removed
complexity: {
cyclomatic: number, // file total
cyclomaticMax: number, // the most complex function
cognitive: number,
cognitiveMax: number
},
churn: {
commits: number,
authors: number,
addedLines: number,
deletedLines: number,
firstSeen: string,
lastChanged: string
} | null,
hotspot: {
score: number, // rank(freq) × rank(complexity)
components: {
changeFrequency: number, // raw
complexity: number, // raw
frequencyRank: number, // percentile
complexityRank: number,
frequencyMinmax: number, // min-max normalisation kept too
complexityMinmax: number
}
},
churnVerification: { // only with --verify-churn
rawCommits: number,
effectiveCommits: number,
effectiveRatio: number,
breakdown: {
changed: number,
"rename-only": number,
"format-only": number,
identical: number,
unknown: number
}
} | null,
health: {
score: number, // 1.0–10.0
smells: [{
kind: "complex-method" | "high-cognitive-complexity"
| "large-file" | "many-arguments",
severity: number, // 0–1
penalty: number, // points this smell cost
value: number, // measured value
threshold: number,
locations: string[], // function names and line numbers
explanation: string // why it is a problem
}],
locBaselineScore: number, // the score you would get from LoC alone
baselineDelta: number // score − locBaselineScore
},
ownership: {
mainDeveloper: string | null, // by added lines
mainDeveloperShare: number,
minorContributors: number, // ownership < 5%
contributors: number
} | null
}hotspot.components keeps the raw values and both normalisations so that you can recompute the score by hand.
packages[]
{
package: string,
files: number,
loc: number,
churnCommits: number,
hotspotSum: number,
hotspotLocWeighted: number,
health: number | null, // LoC-weighted mean
busFactor: {
busFactor: number,
files: number,
orphanedRatio: number,
keyDevelopers: [{ email: string, ownedFiles: number }]
} | null
}packageGraph
{
nodes: string[],
edges: [{
from: string,
to: string,
declared: boolean, // declared in package.json
imported: boolean, // actually imported
importingFiles: number,
typeOnly: boolean // every import is type-only
}]
}declared && !imported is an unused dependency; !declared && imported is a phantom dependency.
coupling
{
filters: { // the filters applied — formula transparency
minRevisions: number,
minShared: number,
minDegree: number,
maxChangesetSize: number,
limit: number
},
excludedLargeChangesets: number,
files: [{ a, b, shared, revisionsA, revisionsB,
degree, confidenceAtoB, confidenceBtoA }],
packages: [{ ...same as above,
kind: "hidden" | "type-only" | "declared-only" | "obvious",
dependencyDirection: "a->b" | "b->a" | "both" | null
}]
} | nullfindings[]
{
kind: "hotspot-file" | "hidden-coupling" | "hotspot-package"
| "bus-factor" | "unhealthy-package" | "unused-dependency",
target: string,
severity: number,
summary: string,
evidence: Record<string, string | number> // the numbers behind it
}evidence always carries the basis for the score. A finding without evidence is not emitted.
config
{
complexity: "loc" | "cyclomatic" | "cognitive",
healthThresholds: { cyclomatic, cognitive, fileLoc, arguments },
healthWeights: Record<string, number>
}Thresholds and weights are part of the output so that everything needed to reproduce the score is in the file.
Examples
dowsing analyze --json | jq '.files[0:5] | .[] | {path, score: .hotspot.score}'# the ten lowest health scores
dowsing analyze --json | jq '[.files[] | {path, health: .health.score}] | sort_by(.health) | .[0:10]'