trustwiki 0.1.0-alpha.0
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.
- package/README.md +87 -0
- package/README.zh.md +82 -0
- package/SKILL.md +76 -0
- package/cli/bin.js +29 -0
- package/cli/citations.js +45 -0
- package/cli/config.js +52 -0
- package/cli/engine.js +99 -0
- package/cli/frontmatter.js +33 -0
- package/cli/links.js +12 -0
- package/cli/report.js +24 -0
- package/cli/resolve.js +25 -0
- package/cli/rules/citation-malformed.js +12 -0
- package/cli/rules/citation-target-missing.js +21 -0
- package/cli/rules/frontmatter-fields.js +39 -0
- package/cli/rules/frontmatter-required.js +12 -0
- package/cli/rules/index.js +19 -0
- package/cli/rules/link-broken.js +22 -0
- package/cli/rules/link-index-missing.js +30 -0
- package/cli/rules/link-type-mismatch.js +16 -0
- package/cli/rules/page-orphan.js +14 -0
- package/cli/rules/placeholder-present.js +17 -0
- package/cli/rules/provenance-contradicted.js +31 -0
- package/cli/rules/provenance-excess-inferred.js +21 -0
- package/cli/rules/provenance-low-confidence.js +15 -0
- package/cli/walk.js +19 -0
- package/docs/method.md +69 -0
- package/docs/method.zh.md +61 -0
- package/package.json +31 -0
- package/proof/STATS.md +18 -0
- package/schema/spec.md +110 -0
- package/schema/spec.zh.md +103 -0
- package/templates/demo-vault/.trustwiki.json +6 -0
- package/templates/demo-vault/index.md +7 -0
- package/templates/demo-vault/notes/conflict-a.md +13 -0
- package/templates/demo-vault/notes/conflict-b.md +12 -0
- package/templates/demo-vault/notes/honest-page.md +13 -0
- package/templates/demo-vault/notes/sloppy-page.md +20 -0
- package/templates/demo-vault/sources/tea.md +12 -0
- package/templates/starter-vault/.trustwiki.json +5 -0
- package/templates/starter-vault/index.md +4 -0
- package/templates/starter-vault/notes/first-note.md +11 -0
- package/templates/starter-vault/sources/example-source.md +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# trustwiki
|
|
2
|
+
|
|
3
|
+
**Knowledge bases your agent can maintain — without lying to you.**
|
|
4
|
+
|
|
5
|
+
Every claim cited. Contradictions surfaced. Rot detected.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx trustwiki lint ./your-vault
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
trustwiki is a provenance linter and an operating method for knowledge bases
|
|
12
|
+
maintained by AI agents. It does **not** build your wiki. It makes sure that
|
|
13
|
+
when your agent does, every claim can be traced to a source, disagreements
|
|
14
|
+
stay visible instead of being silently rewritten, and decay is measured —
|
|
15
|
+
not discovered two months later.
|
|
16
|
+
|
|
17
|
+
- **12 mechanical checks** — citation grammar, citation targets, uncited-inference ratio, confidence floor, contradiction consistency, broken links, index drift, orphans, placeholders
|
|
18
|
+
- **One config file** — `.trustwiki.json`; nothing configured, everything still runs
|
|
19
|
+
- **Zero dependencies** — Node 18+, one command, JSON output for CI
|
|
20
|
+
- **Proven in production** — [operating stats](proof/STATS.md) from an agent-maintained wiki running since 2026-05
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
Agents are already writing knowledge bases. Without a trust layer they
|
|
25
|
+
produce fluent slop: unattributed claims, silent contradiction resolution,
|
|
26
|
+
links that rot. trustwiki is the discipline layer — the linter, the schema,
|
|
27
|
+
and the method.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/QianJinGuo/trustwiki && cd trustwiki
|
|
33
|
+
npx trustwiki lint templates/demo-vault
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You get a report like this (exit code 1 — errors present):
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
notes/sloppy-page.md
|
|
40
|
+
L12 error citation.target-missing citation target not found: sources/ghost.md
|
|
41
|
+
L14 warn provenance.excess-inferred 3/5 prose paragraphs uncited (>0.3)
|
|
42
|
+
L16 warn placeholder.present placeholder text: TODO
|
|
43
|
+
...
|
|
44
|
+
Σ 3 errors, 5 warnings across 3 files
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The eight findings are seeded in `templates/demo-vault` — a vault built to
|
|
48
|
+
fail. `templates/starter-vault` is the same structure built to pass, and is
|
|
49
|
+
the starting point for your own vault.
|
|
50
|
+
|
|
51
|
+
## The schema
|
|
52
|
+
|
|
53
|
+
The citation grammar (`^[path:42-58]`), provenance frontmatter, and
|
|
54
|
+
contradiction marking are specified, versioned, and frozen at
|
|
55
|
+
[schema/spec.md](schema/spec.md) (中文版: [spec.zh.md](schema/spec.zh.md)).
|
|
56
|
+
Implementations other than this linter are welcome.
|
|
57
|
+
|
|
58
|
+
## The method
|
|
59
|
+
|
|
60
|
+
Four phases — **Ingest, Synthesize, Evolve, Gate** — as an installable agent
|
|
61
|
+
skill: [SKILL.md](SKILL.md). Why each rule exists:
|
|
62
|
+
[docs/method.md](docs/method.md) (中文版: [method.zh.md](docs/method.zh.md)).
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
Everything is optional. The two keys that matter:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"roots": ["notes", "sources"],
|
|
71
|
+
"index": "index.md",
|
|
72
|
+
"sourceDir": "sources"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Full reference in [schema/spec.md](schema/spec.md). Rule severities are
|
|
77
|
+
`error | warn | off` per rule; exit codes are `0` clean, `1` errors,
|
|
78
|
+
`2` usage/config.
|
|
79
|
+
|
|
80
|
+
## Stats
|
|
81
|
+
|
|
82
|
+
Numbers from the production vault, each with source and verification date:
|
|
83
|
+
[proof/STATS.md](proof/STATS.md).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# trustwiki
|
|
2
|
+
|
|
3
|
+
**你的 agent 可以维护的知识库——前提是它不对你撒谎。**
|
|
4
|
+
|
|
5
|
+
每个论断有引用。矛盾自动现形。腐烂有仪器测。
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx trustwiki lint ./your-vault
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
trustwiki 是一个溯源 linter,加一套面向 agent 维护知识库的操作方法。
|
|
12
|
+
它**不**替你长出 wiki。它保证的是:当你的 agent 动笔时,每个论断能
|
|
13
|
+
追溯到来源,分歧保持可见而不是被悄悄改写,腐烂被仪器测出来——
|
|
14
|
+
而不是两个月后被人撞见。
|
|
15
|
+
|
|
16
|
+
- **12 条机械检查**——引用文法、引用目标、未引用推断占比、置信度下限、矛盾一致性、断链、索引漂移、孤页、占位符
|
|
17
|
+
- **一个配置文件**——`.trustwiki.json`;什么都不配置也照常能跑
|
|
18
|
+
- **零依赖**——Node 18+,一条命令,JSON 输出可直接进 CI
|
|
19
|
+
- **生产验证**——运行自 2026-05 的 agent 维护知识库的[活体统计](proof/STATS.md)
|
|
20
|
+
|
|
21
|
+
## 为什么
|
|
22
|
+
|
|
23
|
+
agent 已经在替人写知识库了。没有信任层,它们产出的是流畅的 slop:
|
|
24
|
+
无出处的论断、被静默抹平的矛盾、烂掉的链接。trustwiki 是纪律层——
|
|
25
|
+
linter、schema、和方法。
|
|
26
|
+
|
|
27
|
+
## 快速开始
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/QianJinGuo/trustwiki && cd trustwiki
|
|
31
|
+
npx trustwiki lint templates/demo-vault
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
你会得到这样一份报告(退出码 1——存在 error):
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
notes/sloppy-page.md
|
|
38
|
+
L12 error citation.target-missing citation target not found: sources/ghost.md
|
|
39
|
+
L14 warn provenance.excess-inferred 3/5 prose paragraphs uncited (>0.3)
|
|
40
|
+
L16 warn placeholder.present placeholder text: TODO
|
|
41
|
+
...
|
|
42
|
+
Σ 3 errors, 5 warnings across 3 files
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
这 8 条 findings 是预埋在 `templates/demo-vault` 里的一座"造出来就是为了
|
|
46
|
+
失败"的库。`templates/starter-vault` 是同样结构、造出来就是为了通过的库——
|
|
47
|
+
也是你自己建库的起点。
|
|
48
|
+
|
|
49
|
+
## 规范
|
|
50
|
+
|
|
51
|
+
引用文法(`^[path:42-58]`)、provenance frontmatter、矛盾标记,全部
|
|
52
|
+
有版本化的规范:[schema/spec.zh.md](schema/spec.zh.md)(English:
|
|
53
|
+
[spec.md](schema/spec.md))。欢迎 lint 之外的实现。
|
|
54
|
+
|
|
55
|
+
## 方法
|
|
56
|
+
|
|
57
|
+
四个阶段——**Ingest、Synthesize、Evolve、Gate**——封装为可安装的
|
|
58
|
+
agent skill:[SKILL.md](SKILL.md)。每条规则为什么存在:
|
|
59
|
+
[docs/method.zh.md](docs/method.zh.md)(English: [method.md](docs/method.md))。
|
|
60
|
+
|
|
61
|
+
## 配置
|
|
62
|
+
|
|
63
|
+
一切可选。最重要的两个键:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"roots": ["notes", "sources"],
|
|
68
|
+
"index": "index.md",
|
|
69
|
+
"sourceDir": "sources"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
完整参考见 [schema/spec.zh.md](schema/spec.zh.md)。规则级别为
|
|
74
|
+
`error | warn | off`;退出码 `0` 干净、`1` 有 error、`2` 用法/配置错误。
|
|
75
|
+
|
|
76
|
+
## 活体统计
|
|
77
|
+
|
|
78
|
+
来自生产库的数字,每个都带来源和核实日期:[proof/STATS.md](proof/STATS.md)。
|
|
79
|
+
|
|
80
|
+
## 许可
|
|
81
|
+
|
|
82
|
+
MIT。
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trustwiki
|
|
3
|
+
description: Use when an agent creates or edits pages in a trustwiki-style
|
|
4
|
+
knowledge base — enforces claim-level citations, contradiction marking,
|
|
5
|
+
index hygiene, and pre-commit linting so agent-maintained knowledge stays
|
|
6
|
+
trustworthy. Covers the four-phase method (Ingest → Synthesize → Evolve → Gate).
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# trustwiki — the discipline for agent-maintained knowledge bases
|
|
10
|
+
|
|
11
|
+
## The one rule
|
|
12
|
+
|
|
13
|
+
**Never write a claim your vault cannot trace.**
|
|
14
|
+
|
|
15
|
+
Everything below is this rule made mechanical. If you remember only one
|
|
16
|
+
line, remember that one.
|
|
17
|
+
|
|
18
|
+
## Phase: Ingest
|
|
19
|
+
|
|
20
|
+
Sources land before opinions do.
|
|
21
|
+
|
|
22
|
+
1. Create a source page under the source directory (`type: source`) with
|
|
23
|
+
`source_url`, `ingested`, and `sha256` in frontmatter.
|
|
24
|
+
2. The body holds the source's own words. Never paraphrase into the raw
|
|
25
|
+
page — the raw page is evidence, and evidence does not get edited.
|
|
26
|
+
|
|
27
|
+
**Violation consequence:** without the capture-time `sha256`, a silently
|
|
28
|
+
edited source is undetectable. You will cite a page whose claims changed
|
|
29
|
+
under you, and no instrument can tell you.
|
|
30
|
+
|
|
31
|
+
## Phase: Synthesize
|
|
32
|
+
|
|
33
|
+
Every synthesized page:
|
|
34
|
+
|
|
35
|
+
1. Carries at least two outbound wikilinks — pages that reference nothing
|
|
36
|
+
are orphaned islands, and islands rot first.
|
|
37
|
+
2. Links back to the raw source page it was built from.
|
|
38
|
+
3. Ends every prose paragraph with a citation (`^[path.md:42-58]`).
|
|
39
|
+
4. Marks inference as inference — `provenance_state: inferred` and no fake
|
|
40
|
+
citations. A citation that does not trace is worse than no citation.
|
|
41
|
+
|
|
42
|
+
**Violation consequence:** see `templates/demo-vault/notes/sloppy-page.md` —
|
|
43
|
+
one page, eight findings. Fluent text with zero provenance is the failure
|
|
44
|
+
mode this entire method exists to prevent.
|
|
45
|
+
|
|
46
|
+
## Phase: Evolve
|
|
47
|
+
|
|
48
|
+
1. Bump `updated` on every edit. Stale dates are lies about freshness.
|
|
49
|
+
2. When two pages disagree, surface the conflict — body callout
|
|
50
|
+
`> [!contradiction]` **and** frontmatter `contradicted_by`. Never resolve
|
|
51
|
+
a contradiction by quietly rewriting one side.
|
|
52
|
+
3. Keep the index in sync with the pages. Every page appears in the index;
|
|
53
|
+
every index entry resolves.
|
|
54
|
+
|
|
55
|
+
**Violation consequence:** index drift is silent — the vault looks fine
|
|
56
|
+
until someone follows a dead index entry into a page that no longer exists.
|
|
57
|
+
|
|
58
|
+
## Phase: Gate
|
|
59
|
+
|
|
60
|
+
Run the linter before declaring any vault operation complete:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx trustwiki lint ./vault
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- `exit 0` — done (warnings allowed, but list them).
|
|
67
|
+
- `exit 1` — errors remain; fix them before continuing.
|
|
68
|
+
- Re-run until clean. "It probably passes" is not a gate.
|
|
69
|
+
|
|
70
|
+
## Config quick reference
|
|
71
|
+
|
|
72
|
+
See `schema/spec.md` §Checking your vault. Minimum useful config:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{ "roots": ["notes", "sources"], "index": "index.md", "sourceDir": "sources" }
|
|
76
|
+
```
|
package/cli/bin.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { lintVault } from './engine.js';
|
|
4
|
+
import { formatText, formatJson } from './report.js';
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
console.error('usage: trustwiki lint <vault-path> [--json] [--config <file>]');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
if (args[0] !== 'lint' || !args[1] || args[1].startsWith('--')) {
|
|
12
|
+
usage(); process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
const vault = args[1];
|
|
15
|
+
let json = false, configFlag, bad;
|
|
16
|
+
for (let i = 2; i < args.length; i++) {
|
|
17
|
+
if (args[i] === '--json') json = true;
|
|
18
|
+
else if (args[i] === '--config') {
|
|
19
|
+
if (i + 1 >= args.length) { bad = '--config requires a file argument'; break; }
|
|
20
|
+
configFlag = args[++i];
|
|
21
|
+
} else { bad = `unknown argument: ${args[i]}`; break; }
|
|
22
|
+
}
|
|
23
|
+
if (bad) { console.error(`trustwiki: ${bad}`); usage(); process.exit(2); }
|
|
24
|
+
const { config, error } = await loadConfig(vault, configFlag);
|
|
25
|
+
if (error) { console.error(`trustwiki: ${error.message}`); process.exit(2); }
|
|
26
|
+
const findings = await lintVault(config.vaultPath, config);
|
|
27
|
+
console.log(json ? formatJson(findings) : formatText(findings));
|
|
28
|
+
// exitCode (not process.exit) so large stdout writes flush before teardown
|
|
29
|
+
process.exitCode = findings.some(f => f.severity === 'error') ? 1 : 0;
|
package/cli/citations.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const CITATION_RE = /\^\[([^\]\n]*)\]/g;
|
|
2
|
+
const RANGE_ANCHOR = /^(.+)#L(\d+)-L(\d+)$/;
|
|
3
|
+
const RANGE_COLON = /^(.+?):(\d+)-(\d+)$/;
|
|
4
|
+
|
|
5
|
+
export function parseCitation(raw) {
|
|
6
|
+
const trimmed = raw.trim();
|
|
7
|
+
if (!trimmed) return { ok: false, reason: 'empty citation' };
|
|
8
|
+
const sources = [];
|
|
9
|
+
for (const part of trimmed.split(',').map(s => s.trim())) {
|
|
10
|
+
const s = parseSource(part);
|
|
11
|
+
if (!s.ok) return s;
|
|
12
|
+
sources.push(s.source);
|
|
13
|
+
}
|
|
14
|
+
return { ok: true, sources };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseSource(part) {
|
|
18
|
+
let m = part.match(RANGE_ANCHOR);
|
|
19
|
+
if (m) return finish(m[1], Number(m[2]), Number(m[3]), part);
|
|
20
|
+
m = part.match(RANGE_COLON);
|
|
21
|
+
if (m) return finish(m[1], Number(m[2]), Number(m[3]), part);
|
|
22
|
+
return finish(part, null, null, part);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function finish(path, start, end, part) {
|
|
26
|
+
path = path.trim();
|
|
27
|
+
if (!path || /\s/.test(path)) return { ok: false, reason: `bad source syntax: "${part}"` };
|
|
28
|
+
if (start !== null && (start < 1 || end < 1)) return { ok: false, reason: `line numbers must be positive in "${part}"` };
|
|
29
|
+
if (start !== null && start > end) return { ok: false, reason: `reversed line range in "${part}" (start > end)` };
|
|
30
|
+
return { ok: true, source: { path, start, end } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findCitations(body, bodyStartLine) {
|
|
34
|
+
const citations = [], malformed = [];
|
|
35
|
+
const lines = body.split('\n');
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
for (const m of lines[i].matchAll(CITATION_RE)) {
|
|
38
|
+
const parsed = parseCitation(m[1]);
|
|
39
|
+
const at = { raw: m[0], line: bodyStartLine + i };
|
|
40
|
+
if (parsed.ok) citations.push({ ...at, sources: parsed.sources });
|
|
41
|
+
else malformed.push({ ...at, reason: parsed.reason });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { citations, malformed };
|
|
45
|
+
}
|
package/cli/config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const RULE_IDS = [
|
|
5
|
+
'frontmatter.required', 'frontmatter.fields', 'placeholder.present',
|
|
6
|
+
'link.broken', 'link.index-missing', 'link.type-mismatch', 'page.orphan',
|
|
7
|
+
'citation.malformed', 'citation.target-missing',
|
|
8
|
+
'provenance.excess-inferred', 'provenance.low-confidence', 'provenance.contradicted',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SEVERITY = {
|
|
12
|
+
'frontmatter.required': 'error', 'frontmatter.fields': 'error', 'placeholder.present': 'warn',
|
|
13
|
+
'link.broken': 'error', 'link.index-missing': 'warn', 'link.type-mismatch': 'warn', 'page.orphan': 'warn',
|
|
14
|
+
'citation.malformed': 'error', 'citation.target-missing': 'error',
|
|
15
|
+
'provenance.excess-inferred': 'warn', 'provenance.low-confidence': 'warn', 'provenance.contradicted': 'warn',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_CONFIG = {
|
|
19
|
+
roots: ['.'],
|
|
20
|
+
index: null,
|
|
21
|
+
sourceDir: null,
|
|
22
|
+
typeByDir: {},
|
|
23
|
+
minOutboundLinks: 2,
|
|
24
|
+
inferredThreshold: 0.3,
|
|
25
|
+
confidenceFloor: 0.5,
|
|
26
|
+
inferredSkipTypes: ['source'],
|
|
27
|
+
rules: Object.fromEntries(RULE_IDS.map(id => [id, DEFAULT_SEVERITY[id]])),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SEVERITIES = new Set(['error', 'warn', 'off']);
|
|
31
|
+
|
|
32
|
+
export async function loadConfig(vaultPath, explicitPath) {
|
|
33
|
+
const configPath = explicitPath || join(vaultPath, '.trustwiki.json');
|
|
34
|
+
let user = {};
|
|
35
|
+
try {
|
|
36
|
+
user = JSON.parse(await readFile(configPath, 'utf8'));
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (e.code !== 'ENOENT') return { error: { message: `invalid ${configPath}: ${e.message}` } };
|
|
39
|
+
}
|
|
40
|
+
const rules = { ...DEFAULT_CONFIG.rules, ...(user.rules || {}) };
|
|
41
|
+
const unknown = Object.keys(rules).filter(id => !RULE_IDS.includes(id));
|
|
42
|
+
if (unknown.length) return { error: { message: `unknown rule id(s): ${unknown.join(', ')}` } };
|
|
43
|
+
const badSev = Object.entries(rules).filter(([, s]) => !SEVERITIES.has(s));
|
|
44
|
+
if (badSev.length) return { error: { message: `bad severity for: ${badSev.map(([k]) => k).join(', ')} (use error|warn|off)` } };
|
|
45
|
+
const merged = { ...DEFAULT_CONFIG, ...user };
|
|
46
|
+
return { config: {
|
|
47
|
+
vaultPath: resolve(vaultPath), roots: merged.roots, index: merged.index, sourceDir: merged.sourceDir,
|
|
48
|
+
typeByDir: merged.typeByDir || {}, minOutboundLinks: merged.minOutboundLinks,
|
|
49
|
+
inferredThreshold: merged.inferredThreshold, confidenceFloor: merged.confidenceFloor,
|
|
50
|
+
inferredSkipTypes: merged.inferredSkipTypes || [], rules,
|
|
51
|
+
} };
|
|
52
|
+
}
|
package/cli/engine.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { walkVault } from './walk.js';
|
|
4
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
5
|
+
import { extractWikilinks } from './links.js';
|
|
6
|
+
import { findCitations } from './citations.js';
|
|
7
|
+
import { RULES } from './rules/index.js';
|
|
8
|
+
|
|
9
|
+
export function normalizeTarget(t) { return t.trim().replace(/\.md$/, ''); }
|
|
10
|
+
|
|
11
|
+
// Mask fenced code blocks and inline code spans so their contents are never
|
|
12
|
+
// treated as live citations/wikilinks. Character- and line-preserving: every
|
|
13
|
+
// line number in the masked text matches the original file.
|
|
14
|
+
export function maskCode(body) {
|
|
15
|
+
const lines = body.split('\n');
|
|
16
|
+
let inFence = false;
|
|
17
|
+
const masked = lines.map(line => {
|
|
18
|
+
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return ''; }
|
|
19
|
+
if (inFence) return '';
|
|
20
|
+
return line.replace(/`[^`\n]*`/g, m => '`' + ' '.repeat(Math.max(0, m.length - 2)) + '`');
|
|
21
|
+
});
|
|
22
|
+
return masked.join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function proseParagraphs(masked, bodyStartLine) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const countNL = s => (s ? s.split('\n').length - 1 : 0);
|
|
28
|
+
let offset = 0;
|
|
29
|
+
for (const part of masked.split(/(\n[ \t]*\n)/)) {
|
|
30
|
+
const start = offset;
|
|
31
|
+
offset += part.length; // separators are captured parts too
|
|
32
|
+
const trimmed = part.trim();
|
|
33
|
+
if (!trimmed || /^\n[ \t]*\n$/.test(part)) continue;
|
|
34
|
+
const isProse = !/^(#|>|-\s|\||```|\d+\.\s)/.test(trimmed.split('\n')[0]);
|
|
35
|
+
const lead = part.slice(0, part.indexOf(trimmed));
|
|
36
|
+
const startLine = bodyStartLine + countNL(masked.slice(0, start)) + countNL(lead);
|
|
37
|
+
const lastLine = trimmed.split('\n').pop();
|
|
38
|
+
out.push({ text: trimmed, startLine, isProse, lastLine });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function lintVault(vaultPath, config) {
|
|
44
|
+
const files = await walkVault(vaultPath, config.roots, config.index);
|
|
45
|
+
const model = { vaultPath, config, files: [], indexEntries: null, indexLines: null, degraded: [] };
|
|
46
|
+
model.filePaths = new Set(files);
|
|
47
|
+
let indexUnreadable = false;
|
|
48
|
+
if (config.index) {
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(join(vaultPath, config.index), 'utf8');
|
|
51
|
+
model.indexRaw = raw;
|
|
52
|
+
model.indexEntries = new Set();
|
|
53
|
+
model.indexLines = new Map();
|
|
54
|
+
const indexLines = raw.split('\n');
|
|
55
|
+
for (let i = 0; i < indexLines.length; i++) {
|
|
56
|
+
for (const m of indexLines[i].matchAll(/\[\[([^\]|\n]+)/g)) {
|
|
57
|
+
const norm = normalizeTarget(m[1]);
|
|
58
|
+
model.indexEntries.add(norm);
|
|
59
|
+
if (!model.indexLines.has(norm)) model.indexLines.set(norm, i + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
model.indexEntries = null;
|
|
64
|
+
indexUnreadable = true; // configured but unreadable — reported, not silent
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const rel of files) {
|
|
68
|
+
const raw = await readFile(join(vaultPath, rel), 'utf8');
|
|
69
|
+
const text = raw.replace(/\r\n/g, '\n'); // CRLF normalize; line count preserved
|
|
70
|
+
const fm = parseFrontmatter(text);
|
|
71
|
+
const bodyStartLine = fm.bodyStartLine;
|
|
72
|
+
const body = maskCode(fm.body);
|
|
73
|
+
model.files.push({
|
|
74
|
+
relPath: rel, text, fm, body: fm.body, bodyStartLine,
|
|
75
|
+
links: extractWikilinks(body, bodyStartLine),
|
|
76
|
+
...findCitations(body, bodyStartLine),
|
|
77
|
+
paragraphs: proseParagraphs(body, bodyStartLine),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const findings = [];
|
|
81
|
+
for (const rule of RULES) {
|
|
82
|
+
const severity = config.rules[rule.id];
|
|
83
|
+
if (!severity || severity === 'off') continue;
|
|
84
|
+
if (rule.needs === 'index' && !model.indexEntries) { model.degraded.push(rule.id); continue; }
|
|
85
|
+
for (const f of rule.run(model)) {
|
|
86
|
+
findings.push({
|
|
87
|
+
severity, rule: rule.id,
|
|
88
|
+
file: f.file ?? '', line: f.line ?? 1,
|
|
89
|
+
message: f.message, hint: f.hint ?? '',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (indexUnreadable) {
|
|
94
|
+
findings.push({ severity: 'warn', rule: 'config.index-unreadable', file: config.index, line: 1,
|
|
95
|
+
message: 'configured index could not be read; index rules disabled',
|
|
96
|
+
hint: 'check the path or remove the index key from .trustwiki.json' });
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function parseFrontmatter(text) {
|
|
2
|
+
if (!text.startsWith('---')) return { ok: false, reason: 'missing', body: text, bodyStartLine: 1 };
|
|
3
|
+
const lines = text.split('\n');
|
|
4
|
+
// closing --- must be on its own line, not the first
|
|
5
|
+
let endLine = -1;
|
|
6
|
+
for (let i = 1; i < lines.length; i++) {
|
|
7
|
+
if (lines[i].trim() === '---') { endLine = i; break; } // 0-based index i => 1-based line i+1
|
|
8
|
+
}
|
|
9
|
+
if (endLine === -1) return { ok: false, reason: 'unterminated', body: text, bodyStartLine: 1 };
|
|
10
|
+
const fields = {};
|
|
11
|
+
let lastKey = null;
|
|
12
|
+
for (let i = 1; i < endLine; i++) {
|
|
13
|
+
const line = lines[i];
|
|
14
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
15
|
+
const m = line.match(/^([A-Za-z0-9_-]+):\s?(.*)$/);
|
|
16
|
+
if (m && !line.startsWith(' ') && !line.startsWith('-')) {
|
|
17
|
+
const k = m[1].toLowerCase();
|
|
18
|
+
const v = m[2].trim();
|
|
19
|
+
if (v === '' || ['|', '|-', '|+', '>', '>-', '>+'].includes(v)) { fields[k] = ''; lastKey = k; }
|
|
20
|
+
else { fields[k] = v.replace(/^["']|["']$/g, ''); lastKey = null; }
|
|
21
|
+
} else if (lastKey && (line.startsWith(' ') || line.startsWith('- '))) {
|
|
22
|
+
fields[lastKey] = `${fields[lastKey]} ${line.replace(/^(\s+|- )/, '')}`.trim();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const body = lines.slice(endLine + 1).join('\n');
|
|
26
|
+
return { ok: true, fields, body, bodyStartLine: endLine + 2 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseList(value) {
|
|
30
|
+
if (!value) return [];
|
|
31
|
+
const inner = String(value).trim().replace(/^\[|\]$/g, '');
|
|
32
|
+
return inner ? inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean) : [];
|
|
33
|
+
}
|
package/cli/links.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const WIKILINK_RE = /\[\[([^\]|\n]+)(?:\|([^\]\n]*))?\]\]/g;
|
|
2
|
+
|
|
3
|
+
export function extractWikilinks(body, bodyStartLine) {
|
|
4
|
+
const out = [];
|
|
5
|
+
const lines = body.split('\n');
|
|
6
|
+
for (let i = 0; i < lines.length; i++) {
|
|
7
|
+
for (const m of lines[i].matchAll(WIKILINK_RE)) {
|
|
8
|
+
out.push({ target: m[1].trim(), alias: m[2] ? m[2].trim() : null, line: bodyStartLine + i });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
package/cli/report.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const CONTROL = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
|
|
2
|
+
const clean = s => String(s).replace(CONTROL, '');
|
|
3
|
+
|
|
4
|
+
export function formatText(findings) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
const byFile = new Map();
|
|
7
|
+
for (const f of findings) {
|
|
8
|
+
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
|
9
|
+
byFile.get(f.file).push(f);
|
|
10
|
+
}
|
|
11
|
+
for (const [file, fs] of [...byFile.entries()].sort()) {
|
|
12
|
+
lines.push(clean(file));
|
|
13
|
+
for (const f of fs.sort((a, b) => a.line - b.line)) {
|
|
14
|
+
lines.push(` L${String(f.line).padEnd(4)} ${f.severity.padEnd(5)} ${clean(f.rule).padEnd(30)} ${clean(f.message)}`);
|
|
15
|
+
if (f.hint) lines.push(` ↳ ${clean(f.hint)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const errors = findings.filter(f => f.severity === 'error').length;
|
|
19
|
+
const warns = findings.filter(f => f.severity === 'warn').length;
|
|
20
|
+
lines.push(`Σ ${errors} error${errors === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'} across ${byFile.size} file${byFile.size === 1 ? '' : 's'}`);
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatJson(findings) { return JSON.stringify(findings, null, 2); }
|
package/cli/resolve.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function normalizeTarget(t) { return t.trim().replace(/\.md$/, ''); }
|
|
5
|
+
|
|
6
|
+
export function resolveTarget(t, cfg) {
|
|
7
|
+
const cands = [t, `${t}.md`];
|
|
8
|
+
if (cfg.sourceDir) cands.push(`${cfg.sourceDir}/${t}`, `${cfg.sourceDir}/${t}.md`);
|
|
9
|
+
for (const root of cfg.roots || []) {
|
|
10
|
+
if (!root || root === '.') continue;
|
|
11
|
+
cands.push(`${root}/${t}`, `${root}/${t}.md`);
|
|
12
|
+
}
|
|
13
|
+
return cands;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Contained, regular-file existence check. Rejects `../` escapes, symlink
|
|
17
|
+
// escapes, and directory hits — a citation/probe must never leave the vault.
|
|
18
|
+
export function resolveInVault(vaultPath, cand) {
|
|
19
|
+
const vaultRoot = resolve(vaultPath);
|
|
20
|
+
const abs = resolve(vaultRoot, cand);
|
|
21
|
+
if (abs !== vaultRoot && !abs.startsWith(vaultRoot + sep)) return null;
|
|
22
|
+
if (!existsSync(abs)) return null;
|
|
23
|
+
try { if (!statSync(abs).isFile()) return null; } catch { return null; }
|
|
24
|
+
return abs;
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const rule = {
|
|
2
|
+
id: 'citation.malformed',
|
|
3
|
+
run(model) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const f of model.files) for (const m of f.malformed) {
|
|
6
|
+
out.push({ file: f.relPath, line: m.line,
|
|
7
|
+
message: `malformed citation ${m.raw} — ${m.reason}`,
|
|
8
|
+
hint: 'grammar: ^[path(:s-e)?(, path…)*] — see schema/spec.md' });
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { resolveInVault, resolveTarget } from '../resolve.js';
|
|
2
|
+
|
|
3
|
+
export const rule = {
|
|
4
|
+
id: 'citation.target-missing',
|
|
5
|
+
run(model) {
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const f of model.files) for (const c of f.citations) {
|
|
8
|
+
for (const s of c.sources) {
|
|
9
|
+
const hit = resolveTarget(s.path, model.config)
|
|
10
|
+
.map(cand => resolveInVault(model.vaultPath, cand))
|
|
11
|
+
.find(Boolean);
|
|
12
|
+
if (!hit) out.push({ file: f.relPath, line: c.line,
|
|
13
|
+
message: `citation target not found: ${s.path}`,
|
|
14
|
+
hint: model.config.sourceDir
|
|
15
|
+
? `create under ${model.config.sourceDir}/ or fix the path`
|
|
16
|
+
: 'create the source file or fix the path' });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const BASE = ['title', 'created', 'updated', 'type', 'tags'];
|
|
2
|
+
const SOURCE = ['source_url', 'ingested', 'sha256'];
|
|
3
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
|
|
4
|
+
const SHA_RE = /^[0-9a-f]{64}$/i;
|
|
5
|
+
const STATES = new Set(['extracted', 'merged', 'inferred', 'ambiguous']);
|
|
6
|
+
|
|
7
|
+
export const rule = {
|
|
8
|
+
id: 'frontmatter.fields',
|
|
9
|
+
run(model) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const f of model.files) {
|
|
12
|
+
if (!f.fm.ok) continue;
|
|
13
|
+
const isSource = f.fm.fields.type === 'source'
|
|
14
|
+
|| (model.config.sourceDir && f.relPath.startsWith(`${model.config.sourceDir}/`));
|
|
15
|
+
const missing = [...BASE, ...(isSource ? SOURCE : [])].filter(k => !f.fm.fields[k]);
|
|
16
|
+
if (missing.length) out.push({ file: f.relPath, line: 1,
|
|
17
|
+
message: `missing frontmatter field(s): ${missing.join(', ')}`,
|
|
18
|
+
hint: 'see schema/spec.md — Frontmatter' });
|
|
19
|
+
const bad = [];
|
|
20
|
+
for (const k of ['created', 'updated']) {
|
|
21
|
+
if (f.fm.fields[k] && !DATE_RE.test(f.fm.fields[k])) bad.push(`${k} is not an ISO date`);
|
|
22
|
+
}
|
|
23
|
+
if (f.fm.fields.provenance_state && !STATES.has(f.fm.fields.provenance_state)) {
|
|
24
|
+
bad.push(`provenance_state "${f.fm.fields.provenance_state}" is not one of extracted|merged|inferred|ambiguous`);
|
|
25
|
+
}
|
|
26
|
+
const conf = Number(f.fm.fields.confidence);
|
|
27
|
+
if (f.fm.fields.confidence && (Number.isNaN(conf) || conf < 0 || conf > 1)) {
|
|
28
|
+
bad.push('confidence must be a number between 0 and 1');
|
|
29
|
+
}
|
|
30
|
+
if (isSource && f.fm.fields.sha256 && !SHA_RE.test(f.fm.fields.sha256)) {
|
|
31
|
+
bad.push('sha256 must be 64 hex characters');
|
|
32
|
+
}
|
|
33
|
+
if (bad.length) out.push({ file: f.relPath, line: 1,
|
|
34
|
+
message: `invalid frontmatter value(s): ${bad.join('; ')}`,
|
|
35
|
+
hint: 'see schema/spec.md — Frontmatter' });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const rule = {
|
|
2
|
+
id: 'frontmatter.required',
|
|
3
|
+
run(model) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const f of model.files) {
|
|
6
|
+
if (!f.fm.ok) out.push({ file: f.relPath, line: 1,
|
|
7
|
+
message: 'missing or unreadable YAML frontmatter',
|
|
8
|
+
hint: 'add a --- block with title/created/updated/type/tags' });
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
},
|
|
12
|
+
};
|