@kontextmind/kxm 0.7.187 → 0.7.189
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/.claude-plugin/marketplace.json +1 -1
- package/.kxm/README.md +1 -0
- package/docs/README.md +1 -0
- package/docs/architecture/index.md +1 -0
- package/docs/contributing/ci-and-release.md +2 -1
- package/docs/contributing/development.md +2 -2
- package/docs/contributing/writing-docs.md +9 -2
- package/docs/glossary.md +1 -1
- package/docs/guides/context-and-memory.md +2 -0
- package/docs/reference/config-reference.md +1 -0
- package/package.json +2 -2
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/scripts/check-plan-links.d.mts +4 -0
- package/scripts/check-plan-links.mjs +234 -0
package/.kxm/README.md
CHANGED
|
@@ -19,6 +19,7 @@ a `default` workflow, `gates.yaml` and `template-provenance.yaml`.
|
|
|
19
19
|
| `models/inventory.yaml` | The discovered model catalog | Generated; track it for a reviewed snapshot |
|
|
20
20
|
| `config.yaml` | Shared personalization settings | Tracked if the project shares them |
|
|
21
21
|
| `memory/` | Project memory facts that `kxm memory` projects into `AGENTS.md`, `CLAUDE.md` and `GEMINI.md` | Tracked |
|
|
22
|
+
| `knowledge/` | Authored project wiki. The hub-compiled view, when written, is `knowledge/wiki/` | Tracked |
|
|
22
23
|
| `assets/` | Intentional workflow inputs and outputs; `assets/generated/` is for machine output | Tracked intentionally; `generated/` ignored |
|
|
23
24
|
| `tasks/` | Task records from `kxm task` | Your choice |
|
|
24
25
|
| `logs/` | Hub, worker and agent logs | Ignored |
|
package/docs/README.md
CHANGED
|
@@ -110,6 +110,7 @@ KXM connects coding agents through a durable, authenticated [hub](glossary.md#hu
|
|
|
110
110
|
|---|---|---|
|
|
111
111
|
| [Develop KXM](contributing/development.md) | Contributors | Setup, build and verify, packaging, and the repository layout |
|
|
112
112
|
| [CI and release](contributing/ci-and-release.md) | Maintainers | What CI runs on each change and how releases ship |
|
|
113
|
+
| [Project knowledge wiki](../.kxm/knowledge/index.md) | Maintainers | Authored notes on architecture, routing, release, and CI, with file-path sources |
|
|
113
114
|
| [Write KXM documentation](contributing/writing-docs.md) | Contributors | The style rules, the page template and the docs checks |
|
|
114
115
|
| [Test matrix](contributing/test-matrix.md) | Maintainers | Features and use cases mapped to automated evidence |
|
|
115
116
|
| [Assignment runner](contributing/assignment-runner.md) | Maintainers | Developer assignments, witness verification and dual-critic acceptance |
|
|
@@ -39,7 +39,7 @@ together.
|
|
|
39
39
|
|---|---|---|
|
|
40
40
|
| Before you push | Local | `npm run verify` |
|
|
41
41
|
| Pull request, push to `main`, or manual | `ci.yml` (`required`) | Aggregates the lanes below into one pass/fail check named `CI / required` |
|
|
42
|
-
| Pull request and push | `ci.yml` (Docs lint) | `lint:docs` and `check:versions`, always |
|
|
42
|
+
| Pull request and push | `ci.yml` (Docs lint) | `lint:docs` (markdownlint and the `plans/` relative-link check) and `check:versions`, always |
|
|
43
43
|
| Code pull request and code push | `ci.yml` (Unit engine and Unit, Linux Node 24) | `engine.test.ts` split by test name; `permission.test.ts`, `runtime.test.ts`, and `package-install.test.ts` one file at a time; every other unit file in two light shards. Typecheck and `check-generated` run on `light-1` |
|
|
44
44
|
| Code pull request and code push | `ci.yml` (Python control plane) | Python 3.13 `unittest` contracts from the frozen uv workspace against PostgreSQL 17 started by `scripts/ci-postgres-17.sh` |
|
|
45
45
|
| Platform-sensitive pull request | `ci.yml` (Unit, Windows Node 24) | The same unit lanes on `windows-latest` |
|
|
@@ -299,3 +299,4 @@ implying automated coverage.
|
|
|
299
299
|
- [Test matrix](test-matrix.md): which test proves which behavior
|
|
300
300
|
- [Write KXM documentation](writing-docs.md): doc gates and pinned paths
|
|
301
301
|
- [Upgrade KXM](../operations/upgrade.md): the operator side of a new version
|
|
302
|
+
- [Project knowledge wiki](../../.kxm/knowledge/index.md): authored notes for [CI](../../.kxm/knowledge/patterns/ci.md) and [release](../../.kxm/knowledge/patterns/release-flow.md)
|
|
@@ -255,7 +255,7 @@ It runs three scripts in order:
|
|
|
255
255
|
| Step | Script | What it checks |
|
|
256
256
|
|---|---|---|
|
|
257
257
|
| 1 | `npm test` | Build, then `test/core/*.test.ts` and `packages/core/*/tests/unit/*.test.ts`. The script passes `--test-force-exit` so a finished run leaves the process even if a handle is still open; a failing test is still reported before that exit |
|
|
258
|
-
| 2 | `npm run check` | `tsc --noEmit`, `
|
|
258
|
+
| 2 | `npm run check` | `tsc --noEmit`, markdownlint, the `plans/` relative-link check, and version surfaces |
|
|
259
259
|
| 3 | `npm run check:generated` | Generated artifacts are tracked and match the staged copy |
|
|
260
260
|
|
|
261
261
|
Changes to the Python control plane also run its frozen-workspace contract
|
|
@@ -303,7 +303,7 @@ Other scripts you will use:
|
|
|
303
303
|
| `npm run test:simulations` | `test/simulations/*.test.ts` |
|
|
304
304
|
| `npm run test:coverage` | Core suite with coverage floors |
|
|
305
305
|
| `npm run test:coverage:complete` | Core plus simulations with the higher nightly floors |
|
|
306
|
-
| `npm run lint:docs` | Markdown lint
|
|
306
|
+
| `npm run lint:docs` | Markdown lint, then the `plans/` relative-link and heading check |
|
|
307
307
|
| `npm run e2e` | Start Obscura if needed, then Playwright in `test/e2e/` (`node --test` does not collect that directory) |
|
|
308
308
|
| `npm run validate:pr` | The three-minute CI gate; see [CI and release](ci-and-release.md) |
|
|
309
309
|
| `npm run validate:ci` | Coverage suite, `check` and a package dry run (not run by CI today) |
|
|
@@ -297,8 +297,15 @@ exist only in prose, never in command form.
|
|
|
297
297
|
|
|
298
298
|
### Check links
|
|
299
299
|
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
`npm run lint:docs` runs markdownlint, then `scripts/check-plan-links.mjs`.
|
|
301
|
+
That script checks every Markdown link under `plans/`. A relative target has
|
|
302
|
+
to exist. A `#heading` on a Markdown file has to match a heading in that
|
|
303
|
+
file. `related` entries in plan front matter are checked the same way, relative
|
|
304
|
+
to the file. Docs lint runs this on every pull request.
|
|
305
|
+
|
|
306
|
+
For a page outside `plans/`, run this check on the files you changed. It
|
|
307
|
+
prints each relative link whose target is missing, and it does not check
|
|
308
|
+
anchors:
|
|
302
309
|
|
|
303
310
|
```bash
|
|
304
311
|
node --input-type=module -e '
|
package/docs/glossary.md
CHANGED
|
@@ -397,7 +397,7 @@ Project facts stored as state keys with a `proposed → current → superseded`
|
|
|
397
397
|
|
|
398
398
|
### Wiki
|
|
399
399
|
|
|
400
|
-
Markdown pages compiled from a project's context records by `kxm context wiki-compile` (a dry run unless you pass `--out`), with state, decisions, contradictions and history linked to their evidence. `kxm context wiki-lint` checks the result.
|
|
400
|
+
Markdown pages compiled from a project's context records by `kxm context wiki-compile` (a dry run unless you pass `--out`), with state, decisions, contradictions and history linked to their evidence. `kxm context wiki-lint` checks the result. This repository's authored notes live at [`.kxm/knowledge/`](../.kxm/knowledge/index.md) and are not that compiled view.
|
|
401
401
|
|
|
402
402
|
## Configuration and routing
|
|
403
403
|
|
|
@@ -230,6 +230,8 @@ kxm context wiki-lint demo
|
|
|
230
230
|
|
|
231
231
|
Pages land under `.kxm/knowledge/wiki/`: one page per section (`architecture`, `decisions`, `incidents`, `patterns`), a temporal-state page that keeps superseded values visible, a contradictions page, a superseded-history page and `index.md`. Every claim links its record id and source. Compilation never resolves a contradiction. Output is deterministic except the timestamp in `index.md`, so diffs stay small. `wiki-lint` checks for broken references, orphan pages, stale state links and hidden contradictions, and exits 1 on an error.
|
|
232
232
|
|
|
233
|
+
This repository also keeps an authored project wiki at [`.kxm/knowledge/`](../../.kxm/knowledge/index.md). Those pages are maintainer notes with file-path sources. `kxm context wiki-compile` does not read them. With `--out`, the compiled view is still written under `.kxm/knowledge/wiki/`.
|
|
234
|
+
|
|
233
235
|
## Author Git memory
|
|
234
236
|
|
|
235
237
|
Git memory is the durable, reviewed layer: facts a person has approved, versioned with the code. Each fact is a Markdown file with `kxm.memory.v1` front matter.
|
|
@@ -1733,6 +1733,7 @@ Everything under `.kxm/` at the project root falls into one of three groups.
|
|
|
1733
1733
|
| `roles/`, `routes.yaml`, `prices.yaml`, `modes.yaml`, `the role files` | Tracked configuration outside the bundle | You and their commands |
|
|
1734
1734
|
| `config.yaml` | Tracked if the project wants shared preferences; otherwise ignore it | `kxm config set` |
|
|
1735
1735
|
| `memory/`, `skills/`, `candidates/`, `goals/` | Tracked durable records | Their commands |
|
|
1736
|
+
| `knowledge/` | Tracked authored wiki. `knowledge/wiki/` is the compiled view from `kxm context wiki-compile --out` | Maintainers, and `kxm context wiki-compile` for `wiki/` |
|
|
1736
1737
|
| `models/inventory.yaml` | Generated; track it if you want a reviewed snapshot | `kxm models inventory-refresh` |
|
|
1737
1738
|
| `assets/` | Tracked intentionally; `assets/generated/` is ignored | Workflows, `kxm improve`, `kxm session start` |
|
|
1738
1739
|
| `tasks/` | Ignored in the KXM repository; your choice | `kxm task` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontextmind/kxm",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.189",
|
|
4
4
|
"description": "KXM local-first multi-agent orchestration and operator dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "KontextMind",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test:coverage:complete": "npm run build && node scripts/run-bounded.mjs 2400000 node --disable-warning=ExperimentalWarning --experimental-strip-types --test --test-force-exit --test-concurrency=4 --experimental-test-coverage --test-coverage-lines=93 --test-coverage-branches=80 --test-coverage-functions=93 --test-coverage-include=plugins/kxm/src/**/*.ts --test-coverage-exclude=plugins/kxm/src/server.ts --test-coverage-exclude=plugins/kxm/src/mcp-server.ts --test-coverage-exclude=plugins/kxm/src/runtime-supervisor.ts --test-coverage-include=packages/core/*/src/**/*.ts test/core/*.test.ts test/simulations/*.test.ts packages/core/*/tests/unit/*.test.ts",
|
|
45
45
|
"test:coverage": "npm run test:coverage:core",
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
|
-
"lint:docs": "markdownlint-cli2",
|
|
47
|
+
"lint:docs": "markdownlint-cli2 && node scripts/check-plan-links.mjs",
|
|
48
48
|
"check:versions": "node scripts/check-versions.mjs",
|
|
49
49
|
"check": "npm run typecheck && npm run lint:docs && npm run check:versions && node scripts/workforce-lint.mjs",
|
|
50
50
|
"verify": "npm test && npm run check && npm run check:generated",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "kxm",
|
|
4
4
|
"displayName": "KXM",
|
|
5
|
-
"version": "0.7.
|
|
5
|
+
"version": "0.7.189",
|
|
6
6
|
"description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "KontextMind",
|
|
@@ -25462,7 +25462,7 @@ function sessionTokenFixHint(policy) {
|
|
|
25462
25462
|
}
|
|
25463
25463
|
|
|
25464
25464
|
// plugins/kxm/src/mcp-server.ts
|
|
25465
|
-
var VERSION = "0.7.
|
|
25465
|
+
var VERSION = "0.7.189";
|
|
25466
25466
|
var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
25467
25467
|
var inbox = /* @__PURE__ */ new Map();
|
|
25468
25468
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
package/plugins/kxm/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.189";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Relative links and heading anchors under plans/.
|
|
4
|
+
// Docs lint runs this. It does not call the network.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
|
|
12
|
+
export function githubSlug(value) {
|
|
13
|
+
return value.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, "").replace(/ /g, "-");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function headingText(raw) {
|
|
17
|
+
let text = raw.trim().replace(/\s+#+\s*$/, "");
|
|
18
|
+
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
19
|
+
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
20
|
+
text = text.replace(/<([^>\s]+)>/g, "$1");
|
|
21
|
+
text = text.replace(/`([^`]*)`/g, "$1");
|
|
22
|
+
text = text.replace(/<\/?[^>]+>/g, "");
|
|
23
|
+
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
24
|
+
text = text.replace(/"/g, '"').replace(/'/g, "'");
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function headingIds(markdown) {
|
|
29
|
+
const lines = markdown.split("\n");
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
const occurrences = Object.create(null);
|
|
32
|
+
const own = Object.hasOwnProperty;
|
|
33
|
+
let index = 0;
|
|
34
|
+
let fence = null;
|
|
35
|
+
if (lines[0]?.trim() === "---") {
|
|
36
|
+
index = 1;
|
|
37
|
+
while (index < lines.length && lines[index].trim() !== "---") index += 1;
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
function addSlug(text) {
|
|
41
|
+
const original = githubSlug(headingText(text));
|
|
42
|
+
let result = original;
|
|
43
|
+
while (own.call(occurrences, result)) {
|
|
44
|
+
occurrences[original] = (occurrences[original] || 0) + 1;
|
|
45
|
+
result = `${original}-${occurrences[original]}`;
|
|
46
|
+
}
|
|
47
|
+
occurrences[result] = 0;
|
|
48
|
+
ids.add(result);
|
|
49
|
+
}
|
|
50
|
+
for (; index < lines.length; index += 1) {
|
|
51
|
+
const line = lines[index];
|
|
52
|
+
const opened = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
53
|
+
if (opened && !fence) {
|
|
54
|
+
fence = opened[1];
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (fence && new RegExp(`^ {0,3}${fence[0]}{${fence.length},}\\s*$`).test(line)) {
|
|
58
|
+
fence = null;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (fence) continue;
|
|
62
|
+
const atx = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
63
|
+
if (atx) addSlug(atx[2]);
|
|
64
|
+
for (const match of line.matchAll(/\sid=["']([^"']+)["']/g)) ids.add(match[1]);
|
|
65
|
+
for (const match of line.matchAll(/\sname=["']([^"']+)["']/g)) ids.add(match[1]);
|
|
66
|
+
}
|
|
67
|
+
return ids;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function visibleLines(markdown) {
|
|
71
|
+
const lines = markdown.split("\n");
|
|
72
|
+
const visible = [];
|
|
73
|
+
let fence = null;
|
|
74
|
+
let comment = false;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
if (!fence && !comment) {
|
|
77
|
+
const opened = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
78
|
+
if (opened) {
|
|
79
|
+
fence = opened[1];
|
|
80
|
+
visible.push("");
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (fence) {
|
|
85
|
+
if (new RegExp(`^ {0,3}${fence[0]}{${fence.length},}\\s*$`).test(line)) fence = null;
|
|
86
|
+
visible.push("");
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let rest = line;
|
|
90
|
+
let kept = "";
|
|
91
|
+
while (rest.length > 0) {
|
|
92
|
+
if (comment) {
|
|
93
|
+
const end = rest.indexOf("-->");
|
|
94
|
+
if (end < 0) {
|
|
95
|
+
rest = "";
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
comment = false;
|
|
99
|
+
rest = rest.slice(end + 3);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const start = rest.indexOf("<!--");
|
|
103
|
+
if (start < 0) {
|
|
104
|
+
kept += rest;
|
|
105
|
+
rest = "";
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
kept += rest.slice(0, start);
|
|
109
|
+
rest = rest.slice(start + 4);
|
|
110
|
+
comment = true;
|
|
111
|
+
}
|
|
112
|
+
visible.push(kept);
|
|
113
|
+
}
|
|
114
|
+
return visible;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function destinationOf(raw) {
|
|
118
|
+
let dest = raw.trim();
|
|
119
|
+
if (dest.startsWith("<") && dest.endsWith(">")) dest = dest.slice(1, -1).trim();
|
|
120
|
+
dest = dest.replace(/\s+"[^"]*"$/, "").replace(/\s+'[^']*'$/, "");
|
|
121
|
+
return dest;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function problemsForDestination(file, lineNumber, dest) {
|
|
125
|
+
if (!dest || /^[a-z][a-z0-9+.-]*:/i.test(dest)) return [];
|
|
126
|
+
if (dest.startsWith("//")) return [];
|
|
127
|
+
if (dest.startsWith("/")) {
|
|
128
|
+
return [`${file}:${lineNumber}: absolute path is not a repository link: ${dest}`];
|
|
129
|
+
}
|
|
130
|
+
const hash = dest.indexOf("#");
|
|
131
|
+
let pathPart = hash >= 0 ? dest.slice(0, hash) : dest;
|
|
132
|
+
const frag = hash >= 0 ? dest.slice(hash + 1) : "";
|
|
133
|
+
pathPart = pathPart.split("?")[0];
|
|
134
|
+
let decodedPath = pathPart;
|
|
135
|
+
let decodedFrag = frag;
|
|
136
|
+
try { decodedPath = decodeURIComponent(pathPart); } catch { /* keep raw */ }
|
|
137
|
+
try { decodedFrag = decodeURIComponent(frag); } catch { /* keep raw */ }
|
|
138
|
+
if (!decodedPath) {
|
|
139
|
+
if (!decodedFrag) return [];
|
|
140
|
+
const ids = headingIds(readFileSync(file, "utf8"));
|
|
141
|
+
if (!ids.has(decodedFrag)) return [`${file}:${lineNumber}: missing anchor #${decodedFrag}`];
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const target = resolve(dirname(file), decodedPath);
|
|
145
|
+
if (!existsSync(target)) return [`${file}:${lineNumber}: missing ${dest}`];
|
|
146
|
+
if (decodedFrag && target.endsWith(".md")) {
|
|
147
|
+
const ids = headingIds(readFileSync(target, "utf8"));
|
|
148
|
+
if (!ids.has(decodedFrag)) return [`${file}:${lineNumber}: missing anchor ${dest}`];
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function relatedProblems(file, markdown) {
|
|
154
|
+
if (!markdown.startsWith("---\n")) return [];
|
|
155
|
+
const end = markdown.indexOf("\n---", 4);
|
|
156
|
+
if (end < 0) return [];
|
|
157
|
+
const front = markdown.slice(4, end).split("\n");
|
|
158
|
+
const problems = [];
|
|
159
|
+
let inRelated = false;
|
|
160
|
+
for (let index = 0; index < front.length; index += 1) {
|
|
161
|
+
const line = front[index];
|
|
162
|
+
const inline = line.match(/^related:\s*\[(.*)\]\s*$/);
|
|
163
|
+
if (inline) {
|
|
164
|
+
for (const item of inline[1].split(",")) {
|
|
165
|
+
const rel = item.trim().replace(/^["']|["']$/g, "");
|
|
166
|
+
if (!rel || /^[a-z][a-z0-9+.-]*:/i.test(rel)) continue;
|
|
167
|
+
if (!existsSync(resolve(dirname(file), rel))) {
|
|
168
|
+
problems.push(`${file}:${index + 2}: missing related ${rel}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (/^related:\s*$/.test(line)) {
|
|
174
|
+
inRelated = true;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (inRelated && /^\S/.test(line)) inRelated = false;
|
|
178
|
+
if (!inRelated) continue;
|
|
179
|
+
const item = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
180
|
+
if (!item) continue;
|
|
181
|
+
const rel = item[1].replace(/^["']|["']$/g, "");
|
|
182
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rel)) continue;
|
|
183
|
+
if (!existsSync(resolve(dirname(file), rel))) {
|
|
184
|
+
problems.push(`${file}:${index + 2}: missing related ${rel}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return problems;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const linkRe = /(!?\[[^\]\n]*\])\(([^)\n]+)\)/g;
|
|
191
|
+
// Footnote bodies (`[^id]: prose`) are not link destinations. The URL inside
|
|
192
|
+
// the body is an ordinary inline link.
|
|
193
|
+
const refRe = /^ {0,3}\[([^\]^\n][^\]]*)\]:\s+(\S+)/;
|
|
194
|
+
|
|
195
|
+
export function checkFile(file) {
|
|
196
|
+
const markdown = readFileSync(file, "utf8");
|
|
197
|
+
const problems = relatedProblems(file, markdown);
|
|
198
|
+
const lines = visibleLines(markdown);
|
|
199
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
200
|
+
const line = lines[index];
|
|
201
|
+
for (const match of line.matchAll(linkRe)) {
|
|
202
|
+
problems.push(...problemsForDestination(file, index + 1, destinationOf(match[2])));
|
|
203
|
+
}
|
|
204
|
+
const ref = line.match(refRe);
|
|
205
|
+
if (ref) problems.push(...problemsForDestination(file, index + 1, destinationOf(ref[2])));
|
|
206
|
+
}
|
|
207
|
+
return problems;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function walk(dir, out = []) {
|
|
211
|
+
for (const name of readdirSync(dir)) {
|
|
212
|
+
const path = join(dir, name);
|
|
213
|
+
if (statSync(path).isDirectory()) walk(path, out);
|
|
214
|
+
else if (name.endsWith(".md")) out.push(path);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function checkPlans(root = repoRoot) {
|
|
220
|
+
const dir = join(root, "plans");
|
|
221
|
+
return walk(dir).flatMap((file) => checkFile(file));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function main() {
|
|
225
|
+
const problems = checkPlans();
|
|
226
|
+
for (const problem of problems) console.error(problem);
|
|
227
|
+
if (problems.length > 0) {
|
|
228
|
+
console.error(`check-plan-links: ${problems.length} broken`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
234
|
+
if (invoked) main();
|