llmnav 0.5.1
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/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/ROADMAP.md +71 -0
- package/bin/llmnav.js +16 -0
- package/docs/agent-integration.md +114 -0
- package/docs/api.md +290 -0
- package/docs/architecture.md +286 -0
- package/docs/benchmarking.md +164 -0
- package/docs/ci.md +196 -0
- package/docs/cli.md +233 -0
- package/docs/configuration.md +117 -0
- package/docs/editor-integration.md +29 -0
- package/docs/faq.md +59 -0
- package/docs/graph.md +92 -0
- package/docs/language-examples.md +130 -0
- package/docs/migration.md +130 -0
- package/docs/performance-v0.2.md +42 -0
- package/docs/provider-neutral-integration.md +66 -0
- package/docs/publishing.md +86 -0
- package/docs/quickstart.md +139 -0
- package/docs/research.md +31 -0
- package/docs/spec.md +424 -0
- package/examples/provider-neutral-host.d.mts +17 -0
- package/examples/provider-neutral-host.mjs +40 -0
- package/package.json +79 -0
- package/schema/config.schema.json +296 -0
- package/src/agent-protocol.js +117 -0
- package/src/agent-tools.js +61 -0
- package/src/agents.js +127 -0
- package/src/boundaries.js +50 -0
- package/src/changes.js +168 -0
- package/src/cli.js +459 -0
- package/src/config.js +305 -0
- package/src/contracts.js +70 -0
- package/src/declaration.js +334 -0
- package/src/doctor.js +124 -0
- package/src/editor.js +107 -0
- package/src/evaluation.js +67 -0
- package/src/files.js +81 -0
- package/src/formatter.js +23 -0
- package/src/generator.js +528 -0
- package/src/graph-input.js +157 -0
- package/src/graph.js +403 -0
- package/src/incremental.js +262 -0
- package/src/index.d.ts +673 -0
- package/src/index.js +115 -0
- package/src/initializer.js +137 -0
- package/src/inverted-index.js +350 -0
- package/src/parser.js +449 -0
- package/src/project.js +65 -0
- package/src/prompt-bundle.js +108 -0
- package/src/registry.js +107 -0
- package/src/sarif.js +70 -0
- package/src/search-shards.js +75 -0
- package/src/search.js +636 -0
- package/src/spec.d.ts +27 -0
- package/src/spec.js +237 -0
- package/src/tokenizer.js +37 -0
- package/src/transaction.js +557 -0
- package/src/util.js +256 -0
- package/src/validator.js +635 -0
- package/templates/file-card.txt +8 -0
- package/templates/lexicon.json +7 -0
- package/templates/line-card.txt +9 -0
- package/templates/module-card.txt +9 -0
- package/templates/queries.jsonl +1 -0
- package/templates/symbol-card.txt +10 -0
package/src/agents.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.agent.install
|
|
3
|
+
role=Install one managed navigation protocol into instruction files used by major coding agents.
|
|
4
|
+
owns=agent instruction adapters|managed instruction blocks
|
|
5
|
+
excludes=postinstall mutation|agent execution
|
|
6
|
+
search=agent instructions|AGENTS.md integration|coding assistant setup
|
|
7
|
+
rel=workflow>llmnav.search.query
|
|
8
|
+
stability=architecture
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { assertNoSymlinkTraversal, atomicWrite, readText } from "./util.js";
|
|
14
|
+
|
|
15
|
+
const START = "<!-- llmnav:start -->";
|
|
16
|
+
const END = "<!-- llmnav:end -->";
|
|
17
|
+
|
|
18
|
+
export const AGENT_PROTOCOL = `## LLMNav code navigation
|
|
19
|
+
|
|
20
|
+
${START}
|
|
21
|
+
Before broad grep, directory scans, or opening many source files, run \`npm exec -- llmnav query "<task>" --top 5\`.
|
|
22
|
+
|
|
23
|
+
Resolve the selected semantic ID with \`npm exec -- llmnav show <id>\`. Use \`npm exec -- llmnav context <id> --depth 1 --budget 2500\` when related policy, workflow, fallback, migration, or test cards are needed.
|
|
24
|
+
|
|
25
|
+
When the host supports structured tools, load the provider-neutral definitions from \`npm exec -- llmnav tools --json\`. Keep the repository root bound by the host rather than accepting it from model-generated tool input.
|
|
26
|
+
|
|
27
|
+
Read generated cards and signatures before opening full symbol bodies. Treat paths, line numbers, signatures, imports, and hashes as generated data rather than source-of-truth annotations.
|
|
28
|
+
|
|
29
|
+
Keep an existing LLMNav ID when a symbol or file is renamed or moved. Change \`role\`, \`invariant\`, \`effect\`, \`risk\`, and semantic \`rel\` values only when behavior or contracts change.
|
|
30
|
+
|
|
31
|
+
Do not add hand-maintained \`calls\`, \`imports\`, \`references\`, \`implements\`, \`exports\`, or \`overrides\` relations. Do not put paths, line numbers, commit hashes, timestamps, callers, or current signatures in LLMNav source comments.
|
|
32
|
+
|
|
33
|
+
After semantic changes, run \`npm exec -- llmnav format\`, \`npm exec -- llmnav check\`, and \`npm exec -- llmnav generate\`. Use broad text search only when LLMNav returns no credible candidate.
|
|
34
|
+
${END}`;
|
|
35
|
+
|
|
36
|
+
export async function installAgentInstructions(root, adapters = ["agents"]) {
|
|
37
|
+
const selected = normalizeAdapters(adapters);
|
|
38
|
+
const changed = [];
|
|
39
|
+
for (const adapter of selected) {
|
|
40
|
+
if (adapter === "agents") {
|
|
41
|
+
const target = path.join(root, "AGENTS.md");
|
|
42
|
+
await assertNoSymlinkTraversal(root, target, "AGENTS.md");
|
|
43
|
+
if (await upsertMarkdown(target, "# Repository instructions", AGENT_PROTOCOL)) {
|
|
44
|
+
changed.push("AGENTS.md");
|
|
45
|
+
}
|
|
46
|
+
} else if (adapter === "claude") {
|
|
47
|
+
const target = path.join(root, "CLAUDE.md");
|
|
48
|
+
await assertNoSymlinkTraversal(root, target, "CLAUDE.md");
|
|
49
|
+
if (await upsertMarkdown(target, "# Claude Code instructions", AGENT_PROTOCOL)) {
|
|
50
|
+
changed.push("CLAUDE.md");
|
|
51
|
+
}
|
|
52
|
+
} else if (adapter === "copilot") {
|
|
53
|
+
const target = path.join(root, ".github", "copilot-instructions.md");
|
|
54
|
+
await assertNoSymlinkTraversal(root, target, ".github/copilot-instructions.md");
|
|
55
|
+
if (await upsertMarkdown(target, "# GitHub Copilot instructions", AGENT_PROTOCOL)) {
|
|
56
|
+
changed.push(".github/copilot-instructions.md");
|
|
57
|
+
}
|
|
58
|
+
} else if (adapter === "cursor") {
|
|
59
|
+
const target = path.join(root, ".cursor", "rules", "llmnav.mdc");
|
|
60
|
+
await assertNoSymlinkTraversal(root, target, ".cursor/rules/llmnav.mdc");
|
|
61
|
+
const content = `---\ndescription: Use LLMNav before broad codebase exploration\nalwaysApply: true\n---\n\n${AGENT_PROTOCOL}\n`;
|
|
62
|
+
const existing = await readText(target, "");
|
|
63
|
+
if (existing !== content) {
|
|
64
|
+
await atomicWrite(target, content);
|
|
65
|
+
changed.push(".cursor/rules/llmnav.mdc");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const canonicalPath = path.join(root, ".llmnav", "AGENT_INSTRUCTIONS.md");
|
|
71
|
+
await assertNoSymlinkTraversal(root, canonicalPath, ".llmnav/AGENT_INSTRUCTIONS.md");
|
|
72
|
+
const canonical = `# LLMNav agent protocol\n\n${AGENT_PROTOCOL}\n`;
|
|
73
|
+
if ((await readText(canonicalPath, "")) !== canonical) {
|
|
74
|
+
await atomicWrite(canonicalPath, canonical);
|
|
75
|
+
changed.push(".llmnav/AGENT_INSTRUCTIONS.md");
|
|
76
|
+
}
|
|
77
|
+
return changed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeAdapters(adapters) {
|
|
81
|
+
const values = adapters
|
|
82
|
+
.flatMap((value) => String(value).split(","))
|
|
83
|
+
.map((value) => value.trim().toLowerCase())
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
const allowed = new Set(["all", "none", "agents", "claude", "copilot", "cursor"]);
|
|
86
|
+
const unknown = values.filter((value) => !allowed.has(value));
|
|
87
|
+
if (unknown.length > 0) throw adapterUsageError(`Unknown agent adapter(s): ${[...new Set(unknown)].join(", ")}.`);
|
|
88
|
+
if (values.includes("all")) return ["agents", "claude", "copilot", "cursor"];
|
|
89
|
+
if (values.includes("none")) {
|
|
90
|
+
if (values.length > 1) throw adapterUsageError("Agent adapter none cannot be combined with other adapters.");
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
return [...new Set(values)];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
function adapterUsageError(message) {
|
|
98
|
+
const error = new Error(message);
|
|
99
|
+
error.exitCode = 2;
|
|
100
|
+
return error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function upsertMarkdown(filePath, title, block) {
|
|
104
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
105
|
+
const existing = await readText(filePath, "");
|
|
106
|
+
let next;
|
|
107
|
+
const start = existing.indexOf(START);
|
|
108
|
+
const end = existing.indexOf(END);
|
|
109
|
+
if ((start >= 0) !== (end >= 0) || (start >= 0 && end < start)) {
|
|
110
|
+
throw new Error(`${filePath} contains an incomplete LLMNav managed block.`);
|
|
111
|
+
}
|
|
112
|
+
if (start >= 0 && existing.indexOf(START, start + START.length) >= 0) {
|
|
113
|
+
throw new Error(`${filePath} contains multiple LLMNav managed blocks.`);
|
|
114
|
+
}
|
|
115
|
+
if (start >= 0 && end >= start) {
|
|
116
|
+
const before = existing.slice(0, Math.max(0, existing.lastIndexOf("## LLMNav code navigation", start)));
|
|
117
|
+
const after = existing.slice(end + END.length).replace(/^\s+/u, "");
|
|
118
|
+
next = `${before.trimEnd()}${before.trim() ? "\n\n" : ""}${block}\n${after ? `\n${after.trimStart()}` : ""}`;
|
|
119
|
+
} else if (!existing.trim()) {
|
|
120
|
+
next = `${title}\n\n${block}\n`;
|
|
121
|
+
} else {
|
|
122
|
+
next = `${existing.trimEnd()}\n\n${block}\n`;
|
|
123
|
+
}
|
|
124
|
+
if (next === existing) return false;
|
|
125
|
+
await atomicWrite(filePath, next);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.structure.boundaries
|
|
3
|
+
role=Detect route, event, schema, migration, and command boundaries from generated local evidence.
|
|
4
|
+
owns=boundary kinds|confidence assignment|boundary evidence
|
|
5
|
+
excludes=framework execution|source annotations
|
|
6
|
+
search=boundary detection|route schema migration|command event
|
|
7
|
+
rel=workflow>llmnav.index.generate
|
|
8
|
+
stability=architecture
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { compareText, toPosix } from "./util.js";
|
|
13
|
+
|
|
14
|
+
export const BOUNDARY_KINDS = Object.freeze(["command", "event", "migration", "route", "schema"]);
|
|
15
|
+
|
|
16
|
+
export function detectBoundaries(record) {
|
|
17
|
+
const relativePath = toPosix(record.relativePath).toLowerCase();
|
|
18
|
+
const basename = path.posix.basename(relativePath);
|
|
19
|
+
const effects = record.card.effect ?? [];
|
|
20
|
+
const risks = record.card.risk ?? [];
|
|
21
|
+
const boundaries = new Map();
|
|
22
|
+
const add = (kind, confidence, evidence) => {
|
|
23
|
+
const current = boundaries.get(kind);
|
|
24
|
+
const evidences = new Set([...(current?.evidence ?? []), evidence]);
|
|
25
|
+
boundaries.set(kind, {
|
|
26
|
+
kind,
|
|
27
|
+
confidence: current?.confidence === "high" || confidence === "high" ? "high" : "medium",
|
|
28
|
+
evidence: [...evidences].sort(compareText),
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (/(?:^|\/)(?:routes?|api)(?:\/|$)/u.test(relativePath) || /(?:^|\/)route\.[^.]+$/u.test(relativePath)) {
|
|
33
|
+
add("route", "high", "path");
|
|
34
|
+
}
|
|
35
|
+
if (effects.some((effect) => /^event\.(?:emit|consume)\(/u.test(effect))) add("event", "high", "effect");
|
|
36
|
+
else if (/(?:^|\/)(?:events?|consumers?)(?:\/|$)/u.test(relativePath)) add("event", "medium", "path");
|
|
37
|
+
|
|
38
|
+
if (/(?:^|\/)(?:schemas?|contracts?)(?:\/|$)/u.test(relativePath) ||
|
|
39
|
+
/(?:schema\.(?:json|ya?ml)|\.(?:proto|graphql|gql))$/u.test(relativePath)) {
|
|
40
|
+
add("schema", "high", "path");
|
|
41
|
+
}
|
|
42
|
+
if (risks.includes("migration") || /(?:^|\/)(?:migrations?|migrate)(?:\/|$)/u.test(relativePath)) {
|
|
43
|
+
add("migration", "high", risks.includes("migration") ? "risk" : "path");
|
|
44
|
+
}
|
|
45
|
+
if (/(?:^|\/)(?:commands?|cli|bin)(?:\/|$)/u.test(relativePath) || /(?:command|cmd)\.[^.]+$/u.test(basename)) {
|
|
46
|
+
add("command", "high", "path");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [...boundaries.values()].sort((left, right) => compareText(left.kind, right.kind));
|
|
50
|
+
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.index.changes
|
|
3
|
+
role=Describe changed cards, affected boundaries, and generated catalogs as stable machine-readable records.
|
|
4
|
+
owns=card diff|boundary impact report|catalog impact report|change ordering
|
|
5
|
+
excludes=source mutation|cache commit
|
|
6
|
+
search=changed cards|affected boundary|affected catalog|generation report
|
|
7
|
+
rel=workflow>llmnav.index.generate
|
|
8
|
+
stability=architecture
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { compareText, toPosix } from "./util.js";
|
|
12
|
+
|
|
13
|
+
const HASH_DIMENSIONS = Object.freeze(["semantic", "structure", "body"]);
|
|
14
|
+
|
|
15
|
+
export function compareCardIndexes(previousIndex, currentIndex) {
|
|
16
|
+
const previous = new Map((previousIndex?.cards ?? []).map((card) => [card.id, card]));
|
|
17
|
+
const current = new Map((currentIndex?.cards ?? []).map((card) => [card.id, card]));
|
|
18
|
+
const ids = [...new Set([...previous.keys(), ...current.keys()])].sort(compareText);
|
|
19
|
+
const changes = [];
|
|
20
|
+
|
|
21
|
+
for (const id of ids) {
|
|
22
|
+
const before = previous.get(id);
|
|
23
|
+
const after = current.get(id);
|
|
24
|
+
if (!before) {
|
|
25
|
+
changes.push({
|
|
26
|
+
id,
|
|
27
|
+
change: "added",
|
|
28
|
+
dimensions: [...HASH_DIMENSIONS],
|
|
29
|
+
previous: null,
|
|
30
|
+
current: cardSnapshot(after),
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!after) {
|
|
35
|
+
changes.push({
|
|
36
|
+
id,
|
|
37
|
+
change: "removed",
|
|
38
|
+
dimensions: [...HASH_DIMENSIONS],
|
|
39
|
+
previous: cardSnapshot(before),
|
|
40
|
+
current: null,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const dimensions = HASH_DIMENSIONS.filter((dimension) => before.hashes?.[dimension] !== after.hashes?.[dimension]);
|
|
45
|
+
if (dimensions.length === 0) continue;
|
|
46
|
+
changes.push({
|
|
47
|
+
id,
|
|
48
|
+
change: "modified",
|
|
49
|
+
dimensions,
|
|
50
|
+
previous: cardSnapshot(before),
|
|
51
|
+
current: cardSnapshot(after),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return changes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function describeAffectedCatalogs(changedFiles, cacheDirectory, config, previousIndex, currentIndex) {
|
|
58
|
+
const cacheRoot = toPosix(cacheDirectory).replace(/\/+$/u, "");
|
|
59
|
+
const known = new Map([
|
|
60
|
+
[`${cacheRoot}/repo-core.txt`, { kind: "repository", id: "repository" }],
|
|
61
|
+
[`${cacheRoot}/agent-context.md`, { kind: "agent-context", id: "agent-context" }],
|
|
62
|
+
[`${cacheRoot}/prompt-prefix.json`, { kind: "prompt-prefix", id: "prompt-prefix" }],
|
|
63
|
+
]);
|
|
64
|
+
for (const index of [previousIndex, currentIndex]) {
|
|
65
|
+
for (const module of buildModuleCatalogMetadata(index?.cards ?? [], config)) {
|
|
66
|
+
known.set(module.file, { kind: "module", id: module.id });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...new Set(changedFiles.map(toPosix))]
|
|
71
|
+
.filter((file) => known.has(file))
|
|
72
|
+
.sort(compareText)
|
|
73
|
+
.map((file) => ({ file, ...known.get(file) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function describeAffectedBoundaries(changedCards, config, previousIndex, currentIndex) {
|
|
77
|
+
const previous = new Map((previousIndex?.cards ?? []).map((card) => [card.id, card]));
|
|
78
|
+
const current = new Map((currentIndex?.cards ?? []).map((card) => [card.id, card]));
|
|
79
|
+
const allCards = [...previous.values(), ...current.values()];
|
|
80
|
+
|
|
81
|
+
return changedCards.map((change) => {
|
|
82
|
+
const before = previous.get(change.id);
|
|
83
|
+
const after = current.get(change.id);
|
|
84
|
+
const selected = after ?? before;
|
|
85
|
+
const boundaryRecords = new Map();
|
|
86
|
+
for (const card of [before, after]) {
|
|
87
|
+
for (const boundary of card?.boundaries ?? []) {
|
|
88
|
+
const key = `${boundary.kind}:${boundary.confidence}:${(boundary.evidence ?? []).join(",")}`;
|
|
89
|
+
boundaryRecords.set(key, boundary);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const relatedIds = relationTargets(before, after);
|
|
93
|
+
const dependentIds = [...new Set(
|
|
94
|
+
allCards
|
|
95
|
+
.filter((card) => (card.rel ?? []).some((relation) => relationTarget(relation) === change.id))
|
|
96
|
+
.map((card) => card.id),
|
|
97
|
+
)].sort(compareText);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
id: change.id,
|
|
101
|
+
change: change.change,
|
|
102
|
+
dimensions: [...change.dimensions],
|
|
103
|
+
modules: selected?.id ? [moduleIdForCard(selected.id, config.generation.moduleDepth)] : [],
|
|
104
|
+
boundaries: [...boundaryRecords.values()].sort((left, right) => compareText(left.kind, right.kind)),
|
|
105
|
+
relatedIds,
|
|
106
|
+
dependentIds,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildModuleCatalogMetadata(cards, config) {
|
|
112
|
+
const groups = new Map();
|
|
113
|
+
for (const card of cards) {
|
|
114
|
+
if (!config.generation.moduleCatalogStabilities.includes(card.stability)) continue;
|
|
115
|
+
const id = moduleIdForCard(card.id, config.generation.moduleDepth);
|
|
116
|
+
const count = groups.get(id) ?? 0;
|
|
117
|
+
groups.set(id, count + 1);
|
|
118
|
+
}
|
|
119
|
+
const cacheRoot = toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "");
|
|
120
|
+
return [...groups.entries()]
|
|
121
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
122
|
+
.map(([id, cardsCount]) => ({
|
|
123
|
+
id,
|
|
124
|
+
file: `${cacheRoot}/modules/${safeModuleName(id)}.txt`,
|
|
125
|
+
cards: cardsCount,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function moduleIdForCard(id, depth) {
|
|
130
|
+
const segments = String(id).split(".");
|
|
131
|
+
return segments.slice(0, Math.min(Math.max(depth, 1), segments.length)).join(".");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function safeModuleName(id) {
|
|
135
|
+
return String(id).replace(/[^a-z0-9.-]/gu, "-");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cardSnapshot(card) {
|
|
139
|
+
return {
|
|
140
|
+
stability: card?.stability ?? null,
|
|
141
|
+
module: card?.id ? card.id.split(".").slice(0, 2).join(".") : null,
|
|
142
|
+
location: card?.location
|
|
143
|
+
? {
|
|
144
|
+
path: card.location.path,
|
|
145
|
+
symbol: card.location.symbol ?? null,
|
|
146
|
+
declarationLine: card.location.declarationLine ?? null,
|
|
147
|
+
}
|
|
148
|
+
: null,
|
|
149
|
+
hashes: card?.hashes
|
|
150
|
+
? {
|
|
151
|
+
semantic: card.hashes.semantic,
|
|
152
|
+
structure: card.hashes.structure,
|
|
153
|
+
body: card.hashes.body,
|
|
154
|
+
}
|
|
155
|
+
: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function relationTargets(...cards) {
|
|
160
|
+
return [...new Set(
|
|
161
|
+
cards.flatMap((card) => (card?.rel ?? []).map(relationTarget).filter(Boolean)),
|
|
162
|
+
)].sort(compareText);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function relationTarget(relation) {
|
|
166
|
+
const separator = String(relation).indexOf(">");
|
|
167
|
+
return separator >= 0 ? String(relation).slice(separator + 1) : null;
|
|
168
|
+
}
|