brain-wiki 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
- brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
- brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
- brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- brainwiki/__init__.py +17 -0
- brainwiki/__main__.py +5 -0
- brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
- brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
- brainwiki/assets/plugin/.mcp.json +16 -0
- brainwiki/assets/plugin/mcp.json +17 -0
- brainwiki/assets/plugin/mcp_config.json +15 -0
- brainwiki/assets/plugin/plugin.json +37 -0
- brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
- brainwiki/assets/plugin/server.json +30 -0
- brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
- brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
- brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
- brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
- brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
- brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
- brainwiki/assets/prompts/consolidate.md +89 -0
- brainwiki/assets/prompts/distill.md +96 -0
- brainwiki/assets/prompts/harvest.md +142 -0
- brainwiki/bench/__init__.py +8 -0
- brainwiki/bench/_generate.py +440 -0
- brainwiki/bench/_report.py +166 -0
- brainwiki/bench/_suites.py +959 -0
- brainwiki/cli/__init__.py +7 -0
- brainwiki/cli/_cmd_bench.py +101 -0
- brainwiki/cli/_cmd_browse.py +281 -0
- brainwiki/cli/_cmd_consolidate.py +108 -0
- brainwiki/cli/_cmd_core.py +155 -0
- brainwiki/cli/_cmd_harvest.py +137 -0
- brainwiki/cli/_cmd_hosts.py +52 -0
- brainwiki/cli/_cmd_import.py +149 -0
- brainwiki/cli/_cmd_lint.py +117 -0
- brainwiki/cli/_cmd_read.py +131 -0
- brainwiki/cli/_cmd_runs.py +66 -0
- brainwiki/cli/_cmd_viz.py +57 -0
- brainwiki/cli/_cmd_write.py +122 -0
- brainwiki/cli/_main.py +511 -0
- brainwiki/cli/_output.py +19 -0
- brainwiki/config/__init__.py +56 -0
- brainwiki/config/_dirs.py +69 -0
- brainwiki/config/_manifest.py +307 -0
- brainwiki/config/_resolve.py +169 -0
- brainwiki/config/_user.py +183 -0
- brainwiki/consolidate/__init__.py +19 -0
- brainwiki/consolidate/_apply.py +408 -0
- brainwiki/consolidate/_packet.py +489 -0
- brainwiki/consolidate/_pipeline.py +215 -0
- brainwiki/consolidate/_presets.py +230 -0
- brainwiki/consolidate/_review.py +104 -0
- brainwiki/consolidate/_schema.py +153 -0
- brainwiki/consolidate/_validate.py +252 -0
- brainwiki/doctor.py +216 -0
- brainwiki/engine.py +80 -0
- brainwiki/errors.py +81 -0
- brainwiki/format/__init__.py +112 -0
- brainwiki/format/_agents_md.py +26 -0
- brainwiki/format/_claims.py +95 -0
- brainwiki/format/_document.py +203 -0
- brainwiki/format/_edit.py +94 -0
- brainwiki/format/_episode.py +67 -0
- brainwiki/format/_fields.py +350 -0
- brainwiki/format/_frontmatter.py +175 -0
- brainwiki/format/_links.py +72 -0
- brainwiki/format/_meta.py +67 -0
- brainwiki/format/_normalize.py +70 -0
- brainwiki/format/_relations.py +211 -0
- brainwiki/format/_rules.py +41 -0
- brainwiki/harvest.py +414 -0
- brainwiki/hosts/__init__.py +22 -0
- brainwiki/hosts/_antigravity.py +163 -0
- brainwiki/hosts/_claude_code.py +174 -0
- brainwiki/hosts/_codex.py +149 -0
- brainwiki/hosts/_connect.py +443 -0
- brainwiki/hosts/_detect.py +39 -0
- brainwiki/hosts/_instructions.py +220 -0
- brainwiki/hosts/_sessions.py +176 -0
- brainwiki/ids.py +69 -0
- brainwiki/importer/__init__.py +17 -0
- brainwiki/importer/_episodes.py +112 -0
- brainwiki/importer/_into.py +102 -0
- brainwiki/importer/_normalize.py +64 -0
- brainwiki/importer/_overlay.py +199 -0
- brainwiki/importer/_survey.py +167 -0
- brainwiki/index/__init__.py +44 -0
- brainwiki/index/_brains.py +119 -0
- brainwiki/index/_db.py +131 -0
- brainwiki/index/_guards.py +154 -0
- brainwiki/index/_neighbors.py +174 -0
- brainwiki/index/_orient.py +400 -0
- brainwiki/index/_read.py +285 -0
- brainwiki/index/_resolver.py +147 -0
- brainwiki/index/_scanner.py +665 -0
- brainwiki/index/_schema.py +164 -0
- brainwiki/index/_search.py +659 -0
- brainwiki/index/_topics.py +253 -0
- brainwiki/index/_written.py +29 -0
- brainwiki/lifecycle.py +158 -0
- brainwiki/lint/__init__.py +20 -0
- brainwiki/lint/_catalog.py +85 -0
- brainwiki/lint/_cross.py +649 -0
- brainwiki/lint/_files.py +483 -0
- brainwiki/lint/_fixes.py +87 -0
- brainwiki/lint/_hook.py +54 -0
- brainwiki/lint/_model.py +103 -0
- brainwiki/lint/_reports.py +242 -0
- brainwiki/lint/_runner.py +156 -0
- brainwiki/logs.py +34 -0
- brainwiki/mcp/__init__.py +7 -0
- brainwiki/mcp/_server.py +917 -0
- brainwiki/origin.py +97 -0
- brainwiki/runs/__init__.py +21 -0
- brainwiki/runs/_git.py +89 -0
- brainwiki/runs/_lock.py +42 -0
- brainwiki/runs/_record.py +145 -0
- brainwiki/runs/_transaction.py +441 -0
- brainwiki/runs/_undo.py +159 -0
- brainwiki/setup_wizard.py +143 -0
- brainwiki/store/__init__.py +7 -0
- brainwiki/store/_local.py +153 -0
- brainwiki/viz/__init__.py +9 -0
- brainwiki/viz/_export.py +414 -0
- brainwiki/viz/_lod.py +455 -0
- brainwiki/viz/_server.py +327 -0
- brainwiki/viz/app/BUILD_INFO.json +5 -0
- brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
- brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
- brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
- brainwiki/viz/app/index.html +14 -0
- brainwiki/write/__init__.py +23 -0
- brainwiki/write/_edits.py +730 -0
- brainwiki/write/_episode.py +195 -0
- brainwiki/write/_move.py +181 -0
- brainwiki/write/_rules.py +204 -0
- brainwiki/write/_secrets.py +108 -0
brainwiki/lint/_model.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Lint problems, fixes, and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from brainwiki.lint._catalog import fixable as catalog_fixable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Fix:
|
|
14
|
+
description: str
|
|
15
|
+
edit: Callable[[str], str] | None = None # text transform of the target file
|
|
16
|
+
move_to: str | None = None
|
|
17
|
+
create: str | None = None # full content for a file that does not exist yet
|
|
18
|
+
target: str | None = None # file the fix changes, when it is not the problem's file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Problem:
|
|
23
|
+
code: str
|
|
24
|
+
path: str
|
|
25
|
+
line: int | None
|
|
26
|
+
message: str
|
|
27
|
+
severity: str = ""
|
|
28
|
+
fix: Fix | None = None
|
|
29
|
+
fixable: bool = False
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.fix is not None:
|
|
33
|
+
self.fixable = True
|
|
34
|
+
self.fixable = self.fixable and catalog_fixable(self.code)
|
|
35
|
+
|
|
36
|
+
def key(self) -> tuple[str, int, str]:
|
|
37
|
+
return (self.path, self.line or 0, self.code)
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"code": self.code,
|
|
42
|
+
"severity": self.severity,
|
|
43
|
+
"path": self.path,
|
|
44
|
+
"line": self.line,
|
|
45
|
+
"message": self.message,
|
|
46
|
+
"fixable": self.fixable,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
where = f"{self.path}:{self.line}" if self.line else self.path
|
|
51
|
+
return f"{where} {self.code} {self.message}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class LintResult:
|
|
56
|
+
problems: list[Problem] = field(default_factory=list)
|
|
57
|
+
files_checked: int = 0
|
|
58
|
+
seconds: float = 0.0
|
|
59
|
+
|
|
60
|
+
def count(self, severity: str) -> int:
|
|
61
|
+
return sum(1 for p in self.problems if p.severity == severity)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def errors(self) -> int:
|
|
65
|
+
return self.count("error")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def fixable(self) -> int:
|
|
69
|
+
return sum(1 for p in self.problems if p.fixable)
|
|
70
|
+
|
|
71
|
+
def to_json(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"problems": [p.to_json() for p in self.problems],
|
|
74
|
+
"errors": self.errors,
|
|
75
|
+
"warnings": self.count("warning"),
|
|
76
|
+
"info": self.count("info"),
|
|
77
|
+
"fixable": self.fixable,
|
|
78
|
+
"files_checked": self.files_checked,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def format_text(self) -> str:
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
current = None
|
|
84
|
+
for p in self.problems:
|
|
85
|
+
if p.path != current:
|
|
86
|
+
lines.append(p.path)
|
|
87
|
+
current = p.path
|
|
88
|
+
line = str(p.line) if p.line else "-"
|
|
89
|
+
fix = " (fixable)" if p.fixable else ""
|
|
90
|
+
lines.append(f" {line:>4} {p.severity:<7} {p.code} {p.message}{fix}")
|
|
91
|
+
total = len(self.problems)
|
|
92
|
+
if not total:
|
|
93
|
+
return f"No problems ({self.files_checked} files checked)."
|
|
94
|
+
parts = [f"{self.errors} error{'s' if self.errors != 1 else ''}"]
|
|
95
|
+
warnings = self.count("warning")
|
|
96
|
+
parts.append(f"{warnings} warning{'s' if warnings != 1 else ''}")
|
|
97
|
+
if self.count("info"):
|
|
98
|
+
parts.append(f"{self.count('info')} info")
|
|
99
|
+
summary = f"{total} problem{'s' if total != 1 else ''} ({', '.join(parts)})"
|
|
100
|
+
if self.fixable:
|
|
101
|
+
summary += f", {self.fixable} fixable with --fix"
|
|
102
|
+
lines.append(summary)
|
|
103
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""`reports/`: index queries rendered as Markdown pages for people (design doc 06 §7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from brainwiki.ids import is_ulid, ulid_datetime
|
|
10
|
+
from brainwiki.lint._model import LintResult
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from brainwiki.engine import Engine
|
|
14
|
+
|
|
15
|
+
_HEADER = "<!-- Generated by brain-wiki. Do not edit: `brain report` rewrites this file. -->"
|
|
16
|
+
_REPORTS = (
|
|
17
|
+
"index",
|
|
18
|
+
"contradictions",
|
|
19
|
+
"stale",
|
|
20
|
+
"unverified",
|
|
21
|
+
"inbox",
|
|
22
|
+
"duplicates",
|
|
23
|
+
"orphans",
|
|
24
|
+
"lint",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _link(path: str, title: str | None = None) -> str:
|
|
29
|
+
target = path[:-3] if path.lower().endswith(".md") else path
|
|
30
|
+
label = (title or "").replace("|", "/").replace("]", ")")
|
|
31
|
+
return f"[[{target}|{label}]]" if label and label != target else f"[[{target}]]"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _page(title: str, intro: str, lines: list[str], empty: str) -> str:
|
|
35
|
+
body = lines or [empty]
|
|
36
|
+
return "\n".join([_HEADER, f"# {title}", "", intro, "", *body]) + "\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _contradictions(engine: Engine) -> str:
|
|
40
|
+
seen: set[frozenset[str]] = set()
|
|
41
|
+
lines = []
|
|
42
|
+
for row in engine.db.execute(
|
|
43
|
+
"""SELECT a.id AS a_id, a.path AS a_path, a.title AS a_title, b.id AS b_id, b.path AS b_path,
|
|
44
|
+
b.title AS b_title, e.qualifier, e.note, e.attrs
|
|
45
|
+
FROM edges e JOIN nodes a ON a.id = e.src JOIN nodes b ON b.id = e.dst
|
|
46
|
+
WHERE e.rel = 'conflicts_with' AND a.status = 'active' AND b.status = 'active'
|
|
47
|
+
ORDER BY a.path, b.path"""
|
|
48
|
+
):
|
|
49
|
+
pair = frozenset((row["a_id"], row["b_id"]))
|
|
50
|
+
if pair in seen:
|
|
51
|
+
continue
|
|
52
|
+
seen.add(pair)
|
|
53
|
+
extra = " ".join(
|
|
54
|
+
bit
|
|
55
|
+
for bit in (
|
|
56
|
+
f"({row['qualifier']})" if row["qualifier"] else "",
|
|
57
|
+
f"— {row['note']}" if row["note"] else "",
|
|
58
|
+
)
|
|
59
|
+
if bit
|
|
60
|
+
)
|
|
61
|
+
lines.append(
|
|
62
|
+
f"- {_link(row['a_path'], row['a_title'])} ↔ {_link(row['b_path'], row['b_title'])} {extra}".rstrip()
|
|
63
|
+
)
|
|
64
|
+
return _page(
|
|
65
|
+
"Contradictions",
|
|
66
|
+
"Active notes that conflict with each other. Read both before acting on either.",
|
|
67
|
+
lines,
|
|
68
|
+
"No open contradictions.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _stale(engine: Engine) -> str:
|
|
73
|
+
lines = [
|
|
74
|
+
f"- {_link(r['a_path'], r['a_title'])} depends on {_link(r['b_path'], r['b_title'])} ({r['status']})"
|
|
75
|
+
for r in engine.db.execute(
|
|
76
|
+
"""SELECT a.path AS a_path, a.title AS a_title, b.path AS b_path, b.title AS b_title, b.status
|
|
77
|
+
FROM edges e JOIN nodes a ON a.id = e.src JOIN nodes b ON b.id = e.dst
|
|
78
|
+
WHERE e.rel = 'depends_on' AND a.status = 'active' AND b.status IN ('superseded', 'archived')
|
|
79
|
+
ORDER BY a.path"""
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
return _page(
|
|
83
|
+
"Stale dependencies",
|
|
84
|
+
"Active notes that depend on superseded or archived notes; they may need updating.",
|
|
85
|
+
lines,
|
|
86
|
+
"No stale dependencies.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _unverified(engine: Engine) -> str:
|
|
91
|
+
lines = [
|
|
92
|
+
f"- {_link(r['path'], r['title'])}: {r['c']} unverified claim{'s' if r['c'] != 1 else ''}"
|
|
93
|
+
for r in engine.db.execute(
|
|
94
|
+
"""SELECT n.path, n.title, COUNT(*) AS c FROM claims c JOIN nodes n ON n.id = c.node_id
|
|
95
|
+
WHERE c.inputs IN ('external', 'unknown') AND c.status = 'active'
|
|
96
|
+
GROUP BY n.id ORDER BY c DESC, n.path"""
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
return _page(
|
|
100
|
+
"Unverified claims",
|
|
101
|
+
"Notes with active claims derived from untrusted or unknown sources (shown as [unverified] in reads).",
|
|
102
|
+
lines,
|
|
103
|
+
"No unverified claims.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _inbox(engine: Engine) -> str:
|
|
108
|
+
now = datetime.now(UTC)
|
|
109
|
+
by_agent: dict[str, list[tuple[float, str]]] = defaultdict(list)
|
|
110
|
+
for row in engine.db.execute("SELECT path, fm_id, agent FROM nodes WHERE kind = 'episode'"):
|
|
111
|
+
age = (
|
|
112
|
+
(now - ulid_datetime(row["fm_id"])).total_seconds() / 86400
|
|
113
|
+
if is_ulid(row["fm_id"] or "")
|
|
114
|
+
else 0.0
|
|
115
|
+
)
|
|
116
|
+
by_agent[row["agent"] or "unknown"].append((age, row["path"]))
|
|
117
|
+
lines = ["## Pending episodes", ""]
|
|
118
|
+
if not by_agent:
|
|
119
|
+
lines.append("None.")
|
|
120
|
+
for agent, items in sorted(by_agent.items()):
|
|
121
|
+
items.sort(reverse=True)
|
|
122
|
+
lines.append(f"- **{agent}**: {len(items)} pending, oldest {items[0][0]:.0f} days")
|
|
123
|
+
for age, path in items[:20]:
|
|
124
|
+
lines.append(f" - {_link(path)} ({age:.0f} days)")
|
|
125
|
+
if len(items) > 20:
|
|
126
|
+
lines.append(f" - … {len(items) - 20} more")
|
|
127
|
+
lines += ["", "## Open proposals", ""]
|
|
128
|
+
proposals = [
|
|
129
|
+
r["path"]
|
|
130
|
+
for r in engine.db.execute("SELECT path FROM files WHERE kind = 'proposal' ORDER BY path")
|
|
131
|
+
]
|
|
132
|
+
lines += [f"- {_link(p)}" for p in proposals] or ["None."]
|
|
133
|
+
return _page(
|
|
134
|
+
"Inbox", "Episodes waiting for consolidation, and proposals waiting for review.", lines, ""
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _duplicates(result: LintResult) -> str:
|
|
139
|
+
lines = [
|
|
140
|
+
f"- {_link(p.path)} line {p.line}: {p.message}"
|
|
141
|
+
for p in result.problems
|
|
142
|
+
if p.code == "BW206"
|
|
143
|
+
]
|
|
144
|
+
return _page(
|
|
145
|
+
"Duplicate claims",
|
|
146
|
+
"Claims that cite the same source span with near-identical text, usually from consolidating twice.",
|
|
147
|
+
lines,
|
|
148
|
+
"No duplicates.",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _orphans(engine: Engine) -> str:
|
|
153
|
+
lines = [
|
|
154
|
+
f"- {_link(r['path'], r['title'])}"
|
|
155
|
+
for r in engine.db.execute(
|
|
156
|
+
"""SELECT n.path, n.title FROM nodes n WHERE n.kind = 'note' AND NOT EXISTS (
|
|
157
|
+
SELECT 1 FROM edges e WHERE e.other = n.id AND e.declared_in != n.id)
|
|
158
|
+
ORDER BY n.path"""
|
|
159
|
+
)
|
|
160
|
+
]
|
|
161
|
+
return _page("Orphans", "Notes nothing links to and no hub lists.", lines, "No orphans.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _lint_page(result: LintResult) -> str:
|
|
165
|
+
lines = []
|
|
166
|
+
current = None
|
|
167
|
+
for p in result.problems:
|
|
168
|
+
if p.path != current:
|
|
169
|
+
lines.append(f"- {_link(p.path) if p.path.endswith('.md') else '`' + p.path + '`'}")
|
|
170
|
+
current = p.path
|
|
171
|
+
where = f"line {p.line}: " if p.line else ""
|
|
172
|
+
lines.append(f" - {p.severity} {p.code}: {where}{p.message}")
|
|
173
|
+
intro = (
|
|
174
|
+
f"{result.errors} errors, {result.count('warning')} warnings, {result.count('info')} info."
|
|
175
|
+
)
|
|
176
|
+
return _page("Lint", intro, lines, "No problems.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _index_page(engine: Engine, result: LintResult) -> str:
|
|
180
|
+
conn = engine.db
|
|
181
|
+
|
|
182
|
+
def count(sql: str) -> int:
|
|
183
|
+
return int(conn.execute(sql).fetchone()[0])
|
|
184
|
+
|
|
185
|
+
stats = [
|
|
186
|
+
("Notes", count("SELECT COUNT(*) FROM nodes WHERE kind = 'note'")),
|
|
187
|
+
("Hubs", count("SELECT COUNT(*) FROM nodes WHERE kind = 'hub'")),
|
|
188
|
+
("Active claims", count("SELECT COUNT(*) FROM claims WHERE status = 'active'")),
|
|
189
|
+
("Relations", count("SELECT COUNT(*) FROM edges WHERE rel != 'links_to'")),
|
|
190
|
+
("Pending episodes", count("SELECT COUNT(*) FROM nodes WHERE kind = 'episode'")),
|
|
191
|
+
("Archived episodes", count("SELECT COUNT(*) FROM nodes WHERE kind = 'archived_episode'")),
|
|
192
|
+
]
|
|
193
|
+
last = "never"
|
|
194
|
+
try:
|
|
195
|
+
for line in engine.store.read_text("log.md").splitlines():
|
|
196
|
+
if line.startswith("## ") and "· consolidate ·" in line:
|
|
197
|
+
last = line[3:].split(" · ")[0]
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
lines = [f"- {name}: {value}" for name, value in stats]
|
|
201
|
+
lines += [
|
|
202
|
+
f"- Last consolidation: {last}",
|
|
203
|
+
f"- Lint: {result.errors} errors, {result.count('warning')} warnings",
|
|
204
|
+
"",
|
|
205
|
+
"## Reports",
|
|
206
|
+
"",
|
|
207
|
+
]
|
|
208
|
+
lines += [
|
|
209
|
+
f"- [[{engine.manifest.layout.reports_dir}/{name}|{name.title()}]]"
|
|
210
|
+
for name in _REPORTS
|
|
211
|
+
if name != "index"
|
|
212
|
+
]
|
|
213
|
+
generated = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
214
|
+
return _page(f"Brain report: {engine.brain.display_name}", f"Generated {generated}.", lines, "")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def generate(engine: Engine, result: LintResult | None = None) -> list[str]:
|
|
218
|
+
"""Rewrite every report whose content changed. Returns the paths written."""
|
|
219
|
+
from brainwiki.lint._runner import lint
|
|
220
|
+
|
|
221
|
+
result = result or lint(engine)
|
|
222
|
+
pages = {
|
|
223
|
+
"contradictions": _contradictions(engine),
|
|
224
|
+
"stale": _stale(engine),
|
|
225
|
+
"unverified": _unverified(engine),
|
|
226
|
+
"inbox": _inbox(engine),
|
|
227
|
+
"duplicates": _duplicates(result),
|
|
228
|
+
"orphans": _orphans(engine),
|
|
229
|
+
"lint": _lint_page(result),
|
|
230
|
+
}
|
|
231
|
+
written = []
|
|
232
|
+
directory = engine.manifest.layout.reports_dir
|
|
233
|
+
for name, content in pages.items():
|
|
234
|
+
rel = f"{directory}/{name}.md"
|
|
235
|
+
if engine.store.exists(rel) and engine.store.read_text(rel) == content:
|
|
236
|
+
continue
|
|
237
|
+
engine.store.write_text(rel, content)
|
|
238
|
+
written.append(rel)
|
|
239
|
+
index_rel = f"{directory}/index.md"
|
|
240
|
+
engine.store.write_text(index_rel, _index_page(engine, result))
|
|
241
|
+
written.append(index_rel)
|
|
242
|
+
return written
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Run lint over a brain: file rules (cached by content hash) plus cross-file rules from the index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from fnmatch import fnmatch
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from brainwiki.lint._catalog import severity
|
|
12
|
+
from brainwiki.lint._cross import cross_checks
|
|
13
|
+
from brainwiki.lint._files import LINT_VERSION, FileContext, check_file, check_text
|
|
14
|
+
from brainwiki.lint._model import LintResult, Problem
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from brainwiki.engine import Engine
|
|
18
|
+
|
|
19
|
+
_CHECKED_KINDS = (
|
|
20
|
+
"note",
|
|
21
|
+
"hub",
|
|
22
|
+
"episode",
|
|
23
|
+
"archived_episode",
|
|
24
|
+
"agent_file",
|
|
25
|
+
"rules",
|
|
26
|
+
"agents_md",
|
|
27
|
+
"proposal",
|
|
28
|
+
"log",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cache_prefix(engine: Engine) -> str:
|
|
33
|
+
budgets = json.dumps(vars(engine.manifest.budgets), sort_keys=True)
|
|
34
|
+
return f"{LINT_VERSION}:{hashlib.sha256(budgets.encode()).hexdigest()[:8]}:"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _in_scope(path: str, scope: list[str] | None) -> bool:
|
|
38
|
+
if scope is None:
|
|
39
|
+
return True
|
|
40
|
+
for item in scope:
|
|
41
|
+
item = item.rstrip("/")
|
|
42
|
+
if item in ("", ".") or path == item or path.startswith(item + "/"):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _match(code: str, patterns: list[str] | None) -> bool:
|
|
48
|
+
return bool(patterns) and any(code.startswith(p.strip().upper()) for p in patterns or [] if p)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def lint(
|
|
52
|
+
engine: Engine,
|
|
53
|
+
*,
|
|
54
|
+
paths: list[str] | None = None,
|
|
55
|
+
profile: str | None = None,
|
|
56
|
+
select: list[str] | None = None,
|
|
57
|
+
ignore: list[str] | None = None,
|
|
58
|
+
fixes: bool = False,
|
|
59
|
+
staged: dict[str, bytes] | None = None,
|
|
60
|
+
) -> LintResult:
|
|
61
|
+
"""Lint the brain, or only `paths`. `staged` maps paths to the bytes git would commit."""
|
|
62
|
+
started = time.monotonic()
|
|
63
|
+
engine.scan(force=True)
|
|
64
|
+
conn = engine.db
|
|
65
|
+
ctx = FileContext.for_engine(engine)
|
|
66
|
+
manifest = engine.manifest
|
|
67
|
+
scope = list(staged) if staged is not None else paths
|
|
68
|
+
|
|
69
|
+
def profile_for(path: str) -> str:
|
|
70
|
+
for pattern, value in manifest.lint.overrides.items():
|
|
71
|
+
if fnmatch(path, pattern):
|
|
72
|
+
return value
|
|
73
|
+
return profile or manifest.lint.profile
|
|
74
|
+
|
|
75
|
+
problems: list[Problem] = []
|
|
76
|
+
prefix = _cache_prefix(engine)
|
|
77
|
+
cache = {
|
|
78
|
+
row["path"]: (row["sha256"], row["problems"])
|
|
79
|
+
for row in conn.execute("SELECT path, sha256, problems FROM lint_cache")
|
|
80
|
+
}
|
|
81
|
+
updates: list[tuple[str, str, str]] = []
|
|
82
|
+
checked = 0
|
|
83
|
+
rows = conn.execute(
|
|
84
|
+
"""SELECT f.path, f.kind, f.sha256, n.kind AS node_kind
|
|
85
|
+
FROM files f LEFT JOIN nodes n ON n.id = f.node_id ORDER BY f.path"""
|
|
86
|
+
).fetchall()
|
|
87
|
+
kinds = {row["path"]: row["node_kind"] or row["kind"] for row in rows}
|
|
88
|
+
if staged is not None:
|
|
89
|
+
from brainwiki.index import classify
|
|
90
|
+
|
|
91
|
+
for path, data in staged.items():
|
|
92
|
+
kind = kinds.get(path) or classify(engine, path, ctx.topics)
|
|
93
|
+
if kind not in _CHECKED_KINDS or profile_for(path) == "off":
|
|
94
|
+
continue
|
|
95
|
+
checked += 1
|
|
96
|
+
problems += check_file(ctx, path, kind, data, fixes=False)
|
|
97
|
+
else:
|
|
98
|
+
for row in rows:
|
|
99
|
+
path = row["path"]
|
|
100
|
+
kind = kinds[path]
|
|
101
|
+
if kind not in _CHECKED_KINDS or not _in_scope(path, scope):
|
|
102
|
+
continue
|
|
103
|
+
if profile_for(path) == "off":
|
|
104
|
+
continue
|
|
105
|
+
checked += 1
|
|
106
|
+
key = prefix + row["sha256"]
|
|
107
|
+
hit = cache.get(path)
|
|
108
|
+
if hit and hit[0] == key and not fixes:
|
|
109
|
+
problems += [Problem(**p) for p in json.loads(hit[1])]
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
data = engine.store.read_bytes(path)
|
|
113
|
+
except OSError:
|
|
114
|
+
continue
|
|
115
|
+
found = check_file(ctx, path, kind, data, fixes=fixes)
|
|
116
|
+
problems += found
|
|
117
|
+
stored = [
|
|
118
|
+
{
|
|
119
|
+
"code": p.code,
|
|
120
|
+
"path": p.path,
|
|
121
|
+
"line": p.line,
|
|
122
|
+
"message": p.message,
|
|
123
|
+
"fixable": p.fixable,
|
|
124
|
+
}
|
|
125
|
+
for p in found
|
|
126
|
+
]
|
|
127
|
+
updates.append((path, key, json.dumps(stored)))
|
|
128
|
+
if updates:
|
|
129
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
130
|
+
conn.executemany(
|
|
131
|
+
"INSERT OR REPLACE INTO lint_cache(path, sha256, problems) VALUES (?, ?, ?)", updates
|
|
132
|
+
)
|
|
133
|
+
conn.execute("COMMIT")
|
|
134
|
+
problems += [p for p in cross_checks(engine, ctx, fixes=fixes) if _in_scope(p.path, scope)]
|
|
135
|
+
|
|
136
|
+
kept: list[Problem] = []
|
|
137
|
+
for problem in problems:
|
|
138
|
+
if _match(problem.code, ignore) or (select and not _match(problem.code, select)):
|
|
139
|
+
continue
|
|
140
|
+
problem.severity = severity(problem.code, profile_for(problem.path))
|
|
141
|
+
if problem.severity == "off":
|
|
142
|
+
continue
|
|
143
|
+
kept.append(problem)
|
|
144
|
+
kept.sort(key=lambda p: (p.path, p.line or 0, p.code))
|
|
145
|
+
return LintResult(kept, files_checked=checked, seconds=time.monotonic() - started)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def lint_text(engine: Engine, path: str, text: str) -> list[Problem]:
|
|
149
|
+
"""File rules for one file's proposed content (strict), for previews and tests."""
|
|
150
|
+
from brainwiki.index import classify
|
|
151
|
+
|
|
152
|
+
ctx = FileContext.for_engine(engine)
|
|
153
|
+
problems = check_text(ctx, path, classify(engine, path, ctx.topics), text)
|
|
154
|
+
for problem in problems:
|
|
155
|
+
problem.severity = severity(problem.code, "strict")
|
|
156
|
+
return [p for p in problems if p.severity != "off"]
|
brainwiki/logs.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Local log file for long-running processes whose stdout belongs to a protocol (07 §2.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from logging.handlers import RotatingFileHandler
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from brainwiki.config import state_dir
|
|
10
|
+
|
|
11
|
+
_configured = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def log_path() -> Path:
|
|
15
|
+
return state_dir() / "logs" / "brain-wiki.log"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def setup_logging(verbose: bool = False) -> logging.Logger:
|
|
19
|
+
"""Send brain-wiki logs to the rotating state-dir log, never to stdout."""
|
|
20
|
+
global _configured
|
|
21
|
+
logger = logging.getLogger("brainwiki")
|
|
22
|
+
if _configured:
|
|
23
|
+
return logger
|
|
24
|
+
path = log_path()
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
handler = RotatingFileHandler(path, maxBytes=2_000_000, backupCount=3, encoding="utf-8")
|
|
27
|
+
handler.setFormatter(
|
|
28
|
+
logging.Formatter("%(asctime)s %(process)d %(levelname)s %(name)s: %(message)s")
|
|
29
|
+
)
|
|
30
|
+
logger.addHandler(handler)
|
|
31
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
32
|
+
logger.propagate = False
|
|
33
|
+
_configured = True
|
|
34
|
+
return logger
|