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
8 changes: 5 additions & 3 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ url = ""

[health]
project_stale_days = 180
size_threshold = 1200
size_threshold = 4000
# stub_threshold defaults to 20 when omitted.
report_unresolved_links = false
# attachment_extensions defaults to a built-in list when omitted.
# exclude_from_graph defaults to empty (exclude nothing).
Expand Down Expand Up @@ -136,10 +137,11 @@ Obsidian).
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `project_stale_days` | int | `180` | Days without modification before a `1-Projects/` note is flagged as PARA drift. |
| `size_threshold` | int | `1200` | Estimated token count (`len(body)/4`) above which a note is checked for multiple sections (oversized candidate). |
| `size_threshold` | int | `4000` | Estimated token count (`len(body)/4`) above which a multi-section note is flagged as an oversized split candidate. The default targets the genuinely bloated top few percent of notes in a typical vault; tune this per vault if your median note size differs significantly. |
| `stub_threshold` | int | `20` | Estimated token count (`len(body)/4`) below which a note is considered near-empty for the stub detector. A note is a stub candidate only when ALL of: token count is below this threshold, it has zero outbound resolved links, and it is not under `expected_orphan_folders`. |
| `report_unresolved_links` | bool | `false` | List each unresolved wiki-link (a link to a note that does not exist) as a `dangling_link` finding. Obsidian treats these as expected future notes, so they are counted but not listed by default. `hebb audit --unresolved` forces listing for one run. |
| `attachment_extensions` | list of string | built-in list | File extensions (no leading dot) treated as attachment links and excluded from dangling checks, since hebb does not index non-note files. Empty uses the built-in default (`png`, `jpg`, `pdf`, `pptx`, `canvas`, `excalidraw`, ...). Setting it replaces the default rather than extending it. |
| `exclude_from_graph` | list of string | empty | Glob patterns matched against a note's title, basename without `.md`, and vault-relative path (any match excludes the note). A matched note is removed from the link graph before computing connected components, k-core coreness, orphans, leaves, and islands, so machine-generated scaffolding that links to hundreds of notes does not dominate those metrics. Content detectors (`dangling_link`, `ambiguous_link`, `para_drift`, `oversized`) are unaffected and still run over all notes. Patterns use `path.Match` semantics (shell-style globs over the `/`-separated vault path; `*` does not cross `/`). A malformed pattern fails the run with a clear error rather than being silently ignored. Default: empty (exclude nothing). The `--exclude-from-graph` flag on `hebb health` overrides this list for a single run and is useful for ad-hoc experiments. Example: `exclude_from_graph = ["Vault Daily Digest", "Ingest Log", "Action Review", "My Open Actions", "Open Actions*"]` |
| `exclude_from_graph` | list of string | empty | Glob patterns matched against a note's title, basename without `.md`, and vault-relative path (any match excludes the note). A matched note is removed from the link graph before computing connected components, k-core coreness, orphans, leaves, and islands, so machine-generated scaffolding that links to hundreds of notes does not dominate those metrics. Content detectors (`dangling_link`, `ambiguous_link`, `para_drift`, `oversized`, `stub`) are unaffected and still run over all notes. Patterns use `path.Match` semantics (shell-style globs over the `/`-separated vault path; `*` does not cross `/`). A malformed pattern fails the run with a clear error rather than being silently ignored. Default: empty (exclude nothing). The `--exclude-from-graph` flag on `hebb health` overrides this list for a single run and is useful for ad-hoc experiments. Example: `exclude_from_graph = ["Vault Daily Digest", "Ingest Log", "Action Review", "My Open Actions", "Open Actions*"]` |

Folder links (a target ending in `/`, or one naming a real directory) are never
treated as broken note links.
Expand Down
7 changes: 4 additions & 3 deletions cli/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ func healthCmd() *cobra.Command {
" dangling_link wiki-links with no matching note\n" +
" ambiguous_link wiki-links that match more than one note\n" +
" para_drift 1-Projects/ notes that are done or stale\n" +
" oversized notes over the token threshold with multiple sections\n\n" +
" oversized notes over the token threshold with multiple sections\n" +
" stub near-empty notes with no outbound links (merge or archive candidate)\n\n" +
"Wiki-links are resolved case-insensitively (matching Obsidian), and\n" +
"attachment links (.png, .pdf, ...) and folder links are not treated as\n" +
"broken note links. Links to notes that do not exist yet (Obsidian\n" +
Expand Down Expand Up @@ -110,14 +111,14 @@ func healthCmd() *cobra.Command {
"comma-separated glob patterns to drop from the graph for this run (overrides exclude_from_graph in config.toml). "+
"Patterns are matched against a note's title, basename without .md, and vault-relative path. "+
"Excluded notes are removed from graph metrics (coreness, components, orphans, islands) but content "+
"detectors (dangling_link, oversized, ...) still run over them. "+
"detectors (dangling_link, oversized, stub, ...) still run over them. "+
"Example: --exclude-from-graph=\"Vault Daily Digest,Ingest Log,Open Actions*\"")
return c
}

// typeOrder is the fixed display order for finding types. Types not listed here
// appear last in lexicographic order (forward-compatibility with future detectors).
var typeOrder = []string{"dangling_link", "ambiguous_link", "para_drift", "oversized", "orphan", "leaf", "island"}
var typeOrder = []string{"dangling_link", "ambiguous_link", "para_drift", "oversized", "stub", "orphan", "leaf", "island"}

// printGraphSummary writes the one-line structural graph summary to cmd's
// output writer. It is printed above the findings worklist in text mode.
Expand Down
4 changes: 2 additions & 2 deletions cli/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ func buildHealthVaultCLI(t *testing.T) string {
// PARA drift: done project.
write("1-Projects/Done.md", "---\ntitle: Done\nstatus: done\n---\n\nFinished.\n")

// Oversized: token-heavy body with 4 H2 sections.
// Oversized: token-heavy body with 4 H2 sections, above the 4000-token default threshold.
bigBody := strings.Builder{}
bigBody.WriteString("# Big Note\n\n")
for section := 0; section < 4; section++ {
bigBody.WriteString("## Section\n\n")
for line := 0; line < 40; line++ {
for line := 0; line < 160; line++ {
bigBody.WriteString("This is a line of body text in the section to pad out the token count.\n")
}
bigBody.WriteString("\n")
Expand Down
4 changes: 2 additions & 2 deletions core/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1107,8 +1107,8 @@ func TestExcludeFromGraph_ContentDetectorStillRuns(t *testing.T) {
bigBody.WriteString("# Digest\n\n")
for section := 0; section < 4; section++ {
bigBody.WriteString("## Section\n\n")
for line := 0; line < 40; line++ {
bigBody.WriteString("This is a line of body text to pad the token count beyond 1200.\n")
for line := 0; line < 160; line++ {
bigBody.WriteString("This is a line of body text to pad the token count beyond 4000.\n")
}
bigBody.WriteString("\n")
}
Expand Down
124 changes: 119 additions & 5 deletions core/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ var timeNowForTest = time.Now
// index.
type Finding struct {
// Type identifies the finding: "dangling_link", "ambiguous_link",
// "para_drift", "oversized".
// "para_drift", "oversized", "stub".
Type string `json:"type"`
// Path is the vault-relative path of the affected note.
Path string `json:"path"`
Expand Down Expand Up @@ -82,11 +82,17 @@ func RunHealthFull(cfg Config, db *sql.DB, reportUnresolved bool) (HealthResult,
}
all = append(all, os_...)

sb, err := detectStub(cfg, db)
if err != nil {
return HealthResult{}, fmt.Errorf("stub detector: %w", err)
}
all = append(all, sb...)

// Phase 2a: build the graph once (with any exclude_from_graph patterns applied)
// and reuse it for all three graph metrics. Content detectors above this point
// (dangling_link, para_drift, oversized) are unaffected: they run over ALL
// notes regardless of exclusion, because exclusion is about graph centrality
// only, not about hiding note content.
// and reuse it for all three graph metrics. The content detectors above this
// point (dangling_link, ambiguous_link, para_drift, oversized, stub) are
// unaffected: they run over ALL notes regardless of exclusion, because
// exclusion is about graph centrality only, not about hiding note content.
g, err := buildGraphExcluding(db, cfg.Health.GetExcludeFromGraph())
if err != nil {
return HealthResult{}, fmt.Errorf("graph build: %w", err)
Expand Down Expand Up @@ -422,6 +428,114 @@ func detectOversized(cfg Config, db *sql.DB) ([]Finding, error) {
return findings, nil
}

// detectStub finds notes that are genuine dead stubs: near-empty notes with no
// outbound resolved links that are not under the configured expected-orphan
// folders. All three conditions must hold:
//
// 1. The note's estimated token count (len(body)/4) is below
// cfg.Health.GetStubThreshold() (default 20).
// 2. The note has zero outbound resolved links: no row in links where
// source_path = this note AND target_path IS NOT NULL. A thin note that
// links out is likely an intentional map/index stub, not a dead one.
// 3. The note is NOT under any of the expected-orphan folder prefixes
// (cfg.Health.GetExpectedOrphanFolders(), default ["Journal", "Notes",
// "4-Archives"]), where short sparse notes are entirely normal.
//
// The detector runs over all notes regardless of exclude_from_graph (it
// concerns the note's own content and outbound links, not graph centrality).
func detectStub(cfg Config, db *sql.DB) ([]Finding, error) {
threshold := cfg.Health.GetStubThreshold()
orphanFolders := cfg.Health.GetExpectedOrphanFolders()

// Select notes whose body token estimate is below the stub threshold.
rows, err := db.Query(`
SELECT path, body
FROM notes
WHERE (length(body) / 4) < ?
ORDER BY path
`, threshold)
if err != nil {
return nil, err
}
type candidate struct {
path string
tokens int
}
var candidates []candidate
scanErr := func() error {
defer rows.Close()
for rows.Next() {
var path, body string
if err := rows.Scan(&path, &body); err != nil {
return err
}
candidates = append(candidates, candidate{path: path, tokens: len(body) / 4})
}
return rows.Err()
}()
if scanErr != nil {
return nil, scanErr
}

// The set of notes with at least one resolved outbound link, built in a single
// query rather than a COUNT per candidate, so the stub check does not degrade
// into an N+1 pattern that slows `hebb audit` on large vaults.
linked := map[string]bool{}
lrows, err := db.Query(`SELECT DISTINCT source_path FROM links WHERE target_path IS NOT NULL`)
if err != nil {
return nil, err
}
if scanErr := func() error {
defer lrows.Close()
for lrows.Next() {
var p string
if err := lrows.Scan(&p); err != nil {
return err
}
linked[p] = true
}
return lrows.Err()
}(); scanErr != nil {
return nil, scanErr
}

var findings []Finding
for _, c := range candidates {
// Condition 3: skip notes under expected-orphan folders.
if isUnderFolders(c.path, orphanFolders) {
continue
}

// Condition 2: skip notes that have at least one resolved outbound link
// (a thin note that links out is an intentional map or index stub).
if linked[c.path] {
continue
}

findings = append(findings, Finding{
Type: "stub",
Path: c.path,
Detail: fmt.Sprintf("near-empty (~%d tokens), links nowhere - merge or archive?", c.tokens),
Severity: "warn",
})
}
return findings, nil
}

// isUnderFolders reports whether the vault-relative path sits under any of the
// given folder prefixes. The comparison is prefix-based: a folder "Journal"
// matches "Journal/foo.md" but not "JournalExtra/foo.md". The path separator
// is always '/' in vault-relative paths.
func isUnderFolders(path string, folders []string) bool {
for _, folder := range folders {
prefix := strings.TrimSuffix(folder, "/") + "/"
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}

// countSubstantialSections counts the number of H2/H3 headings in raw markdown
// that have at least one non-blank line of body content following them before
// the next H2/H3/H1 heading or end-of-file. This guards against treating a
Expand Down
Loading