@dmthepm/commune 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +468 -0
  3. package/bin/commune.mjs +29 -0
  4. package/lib/cli/check.d.ts +13 -0
  5. package/lib/cli/check.js +58 -0
  6. package/lib/cli/errors.d.ts +29 -0
  7. package/lib/cli/errors.js +41 -0
  8. package/lib/cli/gate.d.ts +34 -0
  9. package/lib/cli/gate.js +165 -0
  10. package/lib/cli/main.d.ts +20 -0
  11. package/lib/cli/main.js +175 -0
  12. package/lib/cli/query.d.ts +30 -0
  13. package/lib/cli/query.js +103 -0
  14. package/lib/cli/related.d.ts +17 -0
  15. package/lib/cli/related.js +177 -0
  16. package/lib/cli/render.d.ts +20 -0
  17. package/lib/cli/render.js +32 -0
  18. package/lib/cli/root.d.ts +11 -0
  19. package/lib/cli/root.js +28 -0
  20. package/lib/cli/usage.d.ts +3 -0
  21. package/lib/cli/usage.js +46 -0
  22. package/lib/cli/version.d.ts +24 -0
  23. package/lib/cli/version.js +29 -0
  24. package/lib/integration.d.ts +24 -0
  25. package/lib/integration.js +111 -0
  26. package/lib/lib/graph.d.ts +354 -0
  27. package/lib/lib/graph.js +774 -0
  28. package/lib/markdown.d.ts +30 -0
  29. package/lib/markdown.js +24 -0
  30. package/lib/rehype-external-links.d.ts +15 -0
  31. package/lib/rehype-external-links.js +46 -0
  32. package/lib/remark-wikilinks.d.ts +25 -0
  33. package/lib/remark-wikilinks.js +108 -0
  34. package/package.json +101 -0
  35. package/src/components/Backlinks.astro +17 -0
  36. package/src/components/BacklinksScript.astro +117 -0
  37. package/src/components/Footer.astro +35 -0
  38. package/src/components/Header.astro +250 -0
  39. package/src/components/HeaderStarScript.astro +380 -0
  40. package/src/components/HomeFooterCards.astro +155 -0
  41. package/src/components/MarkdownLink.astro +36 -0
  42. package/src/components/PlausibleScript.astro +13 -0
  43. package/src/components/RelatedNotes.astro +77 -0
  44. package/src/components/SearchModal.astro +213 -0
  45. package/src/components/StarredLinksScript.astro +92 -0
  46. package/src/components/panes.ts +61 -0
  47. package/src/styles/design-system.css +157 -0
  48. package/src/styles/notes.css +85 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `commune gate` — the build check, as a verb.
3
+ *
4
+ * This is the one command in the CLI whose exit code answers a question about
5
+ * your *content* rather than about the command. Everywhere else the contract is
6
+ * "0 means I finished, findings or not", precisely so an agent can tell a dirty
7
+ * vault from a broken tool. A gate inverts that on purpose: its whole job is to
8
+ * stop a build, and a build stops on a non-zero exit. `usage.ts` says so out
9
+ * loud, because a reader who has internalised the rule needs to be told where
10
+ * the exception is.
11
+ *
12
+ * It was `scripts/test-search-index.mjs`, which imported the graph core by
13
+ * relative path. That works from a checkout and is unreachable from
14
+ * `node_modules`, so the one repo that most needs this check — a wiki built
15
+ * with the package — was the one repo that could not run it. The three
16
+ * assertions are unchanged; only the way you invoke them is.
17
+ *
18
+ * Three assertions:
19
+ * 1. every page in the `pages` collection is present in the search index
20
+ * 2. every WikiLink that resolves uses the target's exact title (no pipes,
21
+ * no case drift) — the canonical-title rule
22
+ * 3. WikiLinks pointing at standalone pages actually render as hrefs
23
+ *
24
+ * The canonical-title rule itself lives in the graph core, where `commune
25
+ * check` reports it as a `noncanonical-title` finding. This verb is the *gate*:
26
+ * same rule, same findings, but a build that violates it stops. Two copies of
27
+ * one rule is the bug #3 was opened to kill, so there is only ever one.
28
+ */
29
+ import { readFile } from 'node:fs/promises';
30
+ import path from 'node:path';
31
+ import { findNoncanonicalTitles, loadContentEntries, stripCode, } from "../lib/graph.js";
32
+ import { EXIT_FAILED, EXIT_OK, failure } from "./errors.js";
33
+ import { SCHEMA, writeJson } from "./render.js";
34
+ const WIKILINK = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
35
+ async function readSearchIndex(root) {
36
+ // The public artifact, not the built one: `public/backlinks.json` is what
37
+ // the site imports at build time and what both repos commit, so it is the
38
+ // copy whose staleness would actually ship.
39
+ const file = path.join(root, 'public', 'backlinks.json');
40
+ try {
41
+ return JSON.parse(await readFile(file, 'utf8'));
42
+ }
43
+ catch (error) {
44
+ throw failure('ENOCONTENT', `could not read the search index at ${file}: ${error instanceof Error ? error.message : String(error)}. ` +
45
+ '`commune gate` runs after a build, which is what writes it.');
46
+ }
47
+ }
48
+ /** 1. Every standalone page made it into the search index. */
49
+ function pagesAreIndexed(pages, index) {
50
+ const missing = [];
51
+ for (const page of pages) {
52
+ const indexed = index[page.urlPath];
53
+ if (!indexed || indexed.title !== page.title || indexed.collection !== 'pages') {
54
+ missing.push(`${page.urlPath} (${page.title})`);
55
+ }
56
+ }
57
+ if (!missing.length)
58
+ return [];
59
+ return [
60
+ {
61
+ assertion: 'pages-indexed',
62
+ message: `standalone pages missing from search index: ${missing.join(', ')}`,
63
+ },
64
+ ];
65
+ }
66
+ /**
67
+ * 2. WikiLinks must name their target exactly.
68
+ *
69
+ * A piped link or a near-miss title still renders, which is what makes this
70
+ * worth checking: it fails silently and quietly decouples the vault from the
71
+ * site.
72
+ */
73
+ function titlesAreCanonical(entries) {
74
+ const noncanonical = findNoncanonicalTitles(entries);
75
+ if (!noncanonical.length)
76
+ return [];
77
+ return [
78
+ {
79
+ assertion: 'canonical-titles',
80
+ message: `WikiLinks must use exact page titles:\n${noncanonical
81
+ .map((finding) => `${finding.file}: ${finding.message}`)
82
+ .join('\n')}`,
83
+ },
84
+ ];
85
+ }
86
+ /**
87
+ * 3. A note that links to a standalone page must actually render that href.
88
+ *
89
+ * The one assertion that catches a resolver regression rather than a content
90
+ * mistake, and the only one that needs the built output.
91
+ */
92
+ async function pageLinksRender(entries, pages, dist) {
93
+ const pageTargets = new Map();
94
+ for (const page of pages) {
95
+ pageTargets.set(page.title.toLowerCase(), page.urlPath);
96
+ for (const alias of page.aliases) {
97
+ pageTargets.set(alias.toLowerCase(), page.urlPath);
98
+ }
99
+ }
100
+ const unresolved = [];
101
+ for (const entry of entries) {
102
+ if (entry.collection !== 'notes')
103
+ continue;
104
+ const expected = new Set();
105
+ for (const match of stripCode(entry.body).matchAll(WIKILINK)) {
106
+ const url = pageTargets.get(match[1].trim().toLowerCase());
107
+ if (url)
108
+ expected.add(url);
109
+ }
110
+ if (!expected.size)
111
+ continue;
112
+ const page = path.join(dist, 'notes', entry.slug, 'index.html');
113
+ let html;
114
+ try {
115
+ html = await readFile(page, 'utf8');
116
+ }
117
+ catch {
118
+ // A page that links to a standalone page and has no built output has
119
+ // failed this assertion as surely as one whose href is wrong — and
120
+ // saying which file is missing beats an ENOENT stack.
121
+ unresolved.push(`${entry.slug} -> ${page} was not built`);
122
+ continue;
123
+ }
124
+ for (const url of expected) {
125
+ if (!html.includes(`href="${url}"`))
126
+ unresolved.push(`${entry.slug} -> ${url}`);
127
+ }
128
+ }
129
+ if (!unresolved.length)
130
+ return [];
131
+ return [
132
+ {
133
+ assertion: 'page-links-rendered',
134
+ message: `WikiLinks to standalone pages did not render: ${unresolved.join(', ')}`,
135
+ },
136
+ ];
137
+ }
138
+ export async function gateCommand(root, dist, json) {
139
+ const index = await readSearchIndex(root);
140
+ const entries = await loadContentEntries({ root });
141
+ const pages = entries.filter((entry) => entry.collection === 'pages');
142
+ const distDir = path.resolve(root, dist);
143
+ // All three run, rather than stopping at the first: a build that broke two
144
+ // things should say so once, not across two rebuilds.
145
+ const failures = [
146
+ ...pagesAreIndexed(pages, index),
147
+ ...titlesAreCanonical(entries),
148
+ ...(await pageLinksRender(entries, pages, distDir)),
149
+ ];
150
+ const passed = failures.length === 0;
151
+ const summary = `${pages.length} standalone page${pages.length === 1 ? '' : 's'} indexed for search and linked from notes`;
152
+ if (json) {
153
+ writeJson({ schema: SCHEMA, passed, failures });
154
+ }
155
+ else {
156
+ // Both verdicts go to stderr, not just the failing one. A gate's output
157
+ // is a diagnostic either way, and keeping stdout empty is what lets
158
+ // `commune gate` sit in a build pipeline without polluting it.
159
+ for (const found of failures)
160
+ process.stderr.write(`FAIL: ${found.message}\n`);
161
+ if (passed)
162
+ process.stderr.write(`PASS: ${summary}\n`);
163
+ }
164
+ return passed ? EXIT_OK : EXIT_FAILED;
165
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Argument parsing and dispatch.
3
+ *
4
+ * `node:util.parseArgs` has no notion of subcommands, so routing is two passes
5
+ * over the same argv. The first is non-strict and only looks for positionals —
6
+ * enough to learn that this is `graph query` — while still consuming `--root`'s
7
+ * value correctly, since declared string options keep their type even in
8
+ * non-strict mode. The second pass is strict against that subcommand's schema,
9
+ * which is what turns a typo into exit 2 instead of a silently ignored flag.
10
+ *
11
+ * Being non-strict first is also what lets a parse *failure* be rendered as
12
+ * JSON: `--json` is known before the strict parse that rejects the argv.
13
+ */
14
+ /**
15
+ * Run the CLI and return its exit code.
16
+ *
17
+ * Returns rather than calls `process.exit`, so a buffered stdout write is never
18
+ * truncated by the process going away underneath it.
19
+ */
20
+ export declare function run(args: string[]): Promise<number>;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Argument parsing and dispatch.
3
+ *
4
+ * `node:util.parseArgs` has no notion of subcommands, so routing is two passes
5
+ * over the same argv. The first is non-strict and only looks for positionals —
6
+ * enough to learn that this is `graph query` — while still consuming `--root`'s
7
+ * value correctly, since declared string options keep their type even in
8
+ * non-strict mode. The second pass is strict against that subcommand's schema,
9
+ * which is what turns a typo into exit 2 instead of a silently ignored flag.
10
+ *
11
+ * Being non-strict first is also what lets a parse *failure* be rendered as
12
+ * JSON: `--json` is known before the strict parse that rejects the argv.
13
+ */
14
+ import { parseArgs } from 'node:util';
15
+ import { CliError, EXIT_OK, EXIT_USAGE, isParseArgsError, usageError } from "./errors.js";
16
+ import { writeError } from "./render.js";
17
+ import { resolveRoot } from "./root.js";
18
+ import { queryCommand } from "./query.js";
19
+ import { checkCommand } from "./check.js";
20
+ import { gateCommand } from "./gate.js";
21
+ import { relatedCommand } from "./related.js";
22
+ import { COMMAND_USAGE, USAGE } from "./usage.js";
23
+ import { readVersion } from "./version.js";
24
+ /** Options understood everywhere, in any position. */
25
+ const GLOBAL = {
26
+ root: { type: 'string' },
27
+ json: { type: 'boolean', default: false },
28
+ help: { type: 'boolean', default: false },
29
+ };
30
+ const QUERY_OPTIONS = {
31
+ ...GLOBAL,
32
+ collection: { type: 'string', multiple: true, default: [] },
33
+ tag: { type: 'string', multiple: true, default: [] },
34
+ status: { type: 'string' },
35
+ orphans: { type: 'boolean', default: false },
36
+ deadends: { type: 'boolean', default: false },
37
+ };
38
+ const GATE_OPTIONS = {
39
+ ...GLOBAL,
40
+ dist: { type: 'string' },
41
+ };
42
+ /** Every route, longest first, so `graph query` is matched before a bare `graph`. */
43
+ const ROUTES = ['graph query', 'graph related', 'check', 'gate'];
44
+ /**
45
+ * Find which command this argv names, without committing to its schema yet.
46
+ *
47
+ * Route words are positionals wherever they appear, so `--root x graph query`
48
+ * and `graph query --root x` route identically — which matters because the two
49
+ * spellings are equally natural and only one of them can be the documented one.
50
+ */
51
+ function route(args) {
52
+ const { positionals, tokens } = parseArgs({
53
+ args,
54
+ options: GLOBAL,
55
+ allowPositionals: true,
56
+ strict: false,
57
+ tokens: true,
58
+ });
59
+ const indices = tokens.filter((token) => token.kind === 'positional').map((token) => token.index);
60
+ for (const candidate of ROUTES) {
61
+ const words = candidate.split(' ');
62
+ if (words.every((word, i) => positionals[i] === word)) {
63
+ const consumed = new Set(indices.slice(0, words.length));
64
+ return { name: candidate, rest: args.filter((_, i) => !consumed.has(i)) };
65
+ }
66
+ }
67
+ if (!positionals.length) {
68
+ throw usageError('no command given', USAGE);
69
+ }
70
+ throw usageError(`unknown command: ${positionals.slice(0, 2).join(' ')}`, USAGE);
71
+ }
72
+ /** Turn `parseArgs`'s typed failures into the CLI's usage error. */
73
+ function parseStrict(args, options, allowPositionals, usage) {
74
+ try {
75
+ return parseArgs({ args, options, allowPositionals, strict: true });
76
+ }
77
+ catch (error) {
78
+ if (isParseArgsError(error))
79
+ throw usageError(error.message, usage);
80
+ throw error;
81
+ }
82
+ }
83
+ async function dispatch(args) {
84
+ const { name, rest } = route(args);
85
+ const usage = COMMAND_USAGE[name];
86
+ switch (name) {
87
+ case 'graph query': {
88
+ const { values } = parseStrict(rest, QUERY_OPTIONS, false, usage);
89
+ if (values.help) {
90
+ process.stdout.write(`${usage}\n`);
91
+ return EXIT_OK;
92
+ }
93
+ const filters = {
94
+ collections: values.collection,
95
+ tags: values.tag,
96
+ status: values.status,
97
+ orphans: values.orphans,
98
+ deadends: values.deadends,
99
+ };
100
+ return queryCommand(await resolveRoot(values.root), filters, values.json);
101
+ }
102
+ case 'graph related': {
103
+ const { values, positionals } = parseStrict(rest, GLOBAL, true, usage);
104
+ if (values.help) {
105
+ process.stdout.write(`${usage}\n`);
106
+ return EXIT_OK;
107
+ }
108
+ if (positionals.length !== 1) {
109
+ throw usageError(positionals.length
110
+ ? `graph related takes one argument, got ${positionals.length}: ${positionals.join(' ')}. A path containing spaces has to be quoted.`
111
+ : 'graph related needs a path, a quoted string of text, or - for stdin', usage);
112
+ }
113
+ return relatedCommand(await resolveRoot(values.root), positionals[0], values.json);
114
+ }
115
+ case 'check': {
116
+ const { values } = parseStrict(rest, GLOBAL, false, usage);
117
+ if (values.help) {
118
+ process.stdout.write(`${usage}\n`);
119
+ return EXIT_OK;
120
+ }
121
+ return checkCommand(await resolveRoot(values.root), values.json);
122
+ }
123
+ case 'gate': {
124
+ const { values } = parseStrict(rest, GATE_OPTIONS, false, usage);
125
+ if (values.help) {
126
+ process.stdout.write(`${usage}\n`);
127
+ return EXIT_OK;
128
+ }
129
+ return gateCommand(await resolveRoot(values.root), values.dist ?? 'dist', values.json);
130
+ }
131
+ default:
132
+ throw usageError(`${name} is not implemented yet`, usage);
133
+ }
134
+ }
135
+ /**
136
+ * Run the CLI and return its exit code.
137
+ *
138
+ * Returns rather than calls `process.exit`, so a buffered stdout write is never
139
+ * truncated by the process going away underneath it.
140
+ */
141
+ export async function run(args) {
142
+ // Read before parsing: the errors most worth emitting as JSON are the ones
143
+ // thrown by the parse itself.
144
+ const json = args.includes('--json');
145
+ try {
146
+ // Before the route table, not in it: `--version` is a question about the
147
+ // installed package, not about a project, so it must answer from
148
+ // anywhere — including a directory with no `src/content` in it, where
149
+ // every real verb exits 1. Plain stdout and nothing else, because the
150
+ // thing most likely to read it is a script.
151
+ //
152
+ // Read out of argv wherever it appears, like `--json` two lines above
153
+ // and for the same reason as `--root`: every other flag in this CLI is
154
+ // position-insensitive, and a `--version` that only worked first would
155
+ // be the one exception nobody would remember. No `-v` alias — `-v` is
156
+ // "verbose" in enough tools to be worth not claiming for something else.
157
+ if (args.includes('--version')) {
158
+ process.stdout.write(`${await readVersion()}\n`);
159
+ return EXIT_OK;
160
+ }
161
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
162
+ process.stdout.write(`${USAGE}\n`);
163
+ return args.length ? EXIT_OK : EXIT_USAGE;
164
+ }
165
+ return await dispatch(args);
166
+ }
167
+ catch (error) {
168
+ if (error instanceof CliError) {
169
+ writeError(error, json);
170
+ return error.exitCode;
171
+ }
172
+ writeError(new CliError('EINTERNAL', error instanceof Error ? error.message : String(error), 1), json);
173
+ return 1;
174
+ }
175
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `commune graph query` — the graph as a list of entries.
3
+ *
4
+ * Each entry carries its resolved `outbound` and `inbound` urlPaths, so a
5
+ * consumer gets the whole neighbourhood in one document rather than one call
6
+ * per note. Filters narrow which entries are *returned*; they never change how
7
+ * links resolve, so an entry's degree is the same whether or not you filtered.
8
+ */
9
+ import { type CollectionName } from '../lib/graph.ts';
10
+ export interface QueryFilters {
11
+ collections: string[];
12
+ tags: string[];
13
+ status?: string;
14
+ orphans: boolean;
15
+ deadends: boolean;
16
+ }
17
+ export interface QueryEntry {
18
+ urlPath: string;
19
+ title: string;
20
+ collection: CollectionName;
21
+ file: string;
22
+ slug: string;
23
+ tags: string[];
24
+ status: string;
25
+ aliases: string[];
26
+ updated?: string;
27
+ outbound: string[];
28
+ inbound: string[];
29
+ }
30
+ export declare function queryCommand(root: string, filters: QueryFilters, json: boolean): Promise<number>;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `commune graph query` — the graph as a list of entries.
3
+ *
4
+ * Each entry carries its resolved `outbound` and `inbound` urlPaths, so a
5
+ * consumer gets the whole neighbourhood in one document rather than one call
6
+ * per note. Filters narrow which entries are *returned*; they never change how
7
+ * links resolve, so an entry's degree is the same whether or not you filtered.
8
+ */
9
+ import { buildGraph, loadContentEntries, } from "../lib/graph.js";
10
+ import { SCHEMA, writeJson, writeLines } from "./render.js";
11
+ import { EXIT_OK } from "./errors.js";
12
+ /** Project one loaded entry against the built graph. */
13
+ function toQueryEntry(entry, graph) {
14
+ const node = graph.nodes[entry.urlPath];
15
+ return {
16
+ urlPath: entry.urlPath,
17
+ title: entry.title,
18
+ collection: entry.collection,
19
+ file: entry.file,
20
+ slug: entry.slug,
21
+ tags: entry.tags,
22
+ status: entry.status,
23
+ aliases: entry.aliases,
24
+ ...(entry.updated ? { updated: entry.updated } : {}),
25
+ outbound: node.outbound,
26
+ inbound: node.inbound,
27
+ };
28
+ }
29
+ /**
30
+ * Isolated: nothing links here and this links nowhere.
31
+ *
32
+ * Not "nobody links to it" — a note that links out but is linked to by nothing
33
+ * is a dead end read from the other direction, and conflating the two is what
34
+ * makes Obsidian's orphan list useless on a vault where most notes link out.
35
+ * One definition, used by both the filter and the summary, so `--orphans` can
36
+ * never disagree with `summary.orphans`.
37
+ */
38
+ function isOrphan(entry) {
39
+ return entry.inbound.length === 0 && entry.outbound.length === 0;
40
+ }
41
+ /** Links to nothing. Whether anything links *here* is a separate question. */
42
+ function isDeadend(entry) {
43
+ return entry.outbound.length === 0;
44
+ }
45
+ /**
46
+ * Repeated values of one flag widen the match; different flags narrow it.
47
+ *
48
+ * `--collection notes --collection pages --tag seed` means "a note or a page,
49
+ * which is also tagged seed" — the reading that lets a caller build up a query
50
+ * without the flags fighting each other.
51
+ */
52
+ function matches(entry, filters) {
53
+ if (filters.collections.length && !filters.collections.includes(entry.collection))
54
+ return false;
55
+ if (filters.tags.length && !entry.tags.some((tag) => filters.tags.includes(tag)))
56
+ return false;
57
+ if (filters.status !== undefined && entry.status !== filters.status)
58
+ return false;
59
+ if (filters.orphans && !isOrphan(entry))
60
+ return false;
61
+ if (filters.deadends && !isDeadend(entry))
62
+ return false;
63
+ return true;
64
+ }
65
+ /**
66
+ * What came back, counted.
67
+ *
68
+ * Describes the *result set*, not the corpus, so it never contradicts `count`
69
+ * beside it. Degrees are still whole-graph — filtering changes which entries
70
+ * are returned, never how their links resolved — so an unfiltered query's
71
+ * `edges` is the same number `check` reports, counted from the outbound end
72
+ * rather than the inbound one.
73
+ *
74
+ * Free to compute: `outbound` and `inbound` are already materialized on every
75
+ * node by the time a query can be filtered at all.
76
+ */
77
+ function summarize(results) {
78
+ return {
79
+ entries: results.length,
80
+ edges: results.reduce((total, entry) => total + entry.outbound.length, 0),
81
+ orphans: results.filter(isOrphan).length,
82
+ deadends: results.filter(isDeadend).length,
83
+ };
84
+ }
85
+ export async function queryCommand(root, filters, json) {
86
+ const entries = await loadContentEntries({ root });
87
+ const graph = buildGraph(entries);
88
+ const results = entries.map((entry) => toQueryEntry(entry, graph)).filter((entry) => matches(entry, filters));
89
+ const summary = summarize(results);
90
+ if (json) {
91
+ // `count` predates `summary` and stays as its alias: the field shipped in
92
+ // the contract #10 and #19 are written against, and removing it would be
93
+ // a schema bump for no gain.
94
+ writeJson({ schema: SCHEMA, root, count: summary.entries, summary, entries: results });
95
+ return EXIT_OK;
96
+ }
97
+ writeLines([
98
+ ...results.map((entry) => `${entry.urlPath}\t${entry.title}\t${entry.collection}\t${entry.status}\t` +
99
+ `→${entry.outbound.length} ←${entry.inbound.length}`),
100
+ `${summary.entries} entries, ${summary.edges} edges, ${summary.orphans} orphans, ${summary.deadends} dead ends`,
101
+ ]);
102
+ return EXIT_OK;
103
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `commune graph related` — what a piece of text connects to.
3
+ *
4
+ * This is the primitive the connect step (#19) and the authoring skills (#10)
5
+ * are written against, so v1 is deliberately deterministic: two buckets, both
6
+ * computable from what the core already does, and no ranking.
7
+ *
8
+ * `links` — every edge the text actually contains, with its resolution.
9
+ * `mentions` — entries whose title or alias appears as a whole word in the
10
+ * prose, which is the "you wrote about this without linking it"
11
+ * signal a connect step needs.
12
+ *
13
+ * Fuzzy and semantic similarity are out of scope on purpose. They are product
14
+ * direction, not a build decision, and inventing a ranking here would freeze it
15
+ * into the contract before anyone chose it.
16
+ */
17
+ export declare function relatedCommand(root: string, input: string, json: boolean): Promise<number>;