world-building-framework 0.0.0-stage → 0.2.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/LICENSE +9 -0
- package/README.md +104 -2
- package/dist/cli/check.d.ts +13 -0
- package/dist/cli/check.js +11 -0
- package/dist/cli/commands/create.d.ts +26 -0
- package/dist/cli/commands/create.js +35 -0
- package/dist/cli/commands/init.d.ts +26 -0
- package/dist/cli/commands/init.js +119 -0
- package/dist/cli/commands/list-variants.d.ts +1 -0
- package/dist/cli/commands/list-variants.js +7 -0
- package/dist/cli/commands/publish.d.ts +24 -0
- package/dist/cli/commands/publish.js +71 -0
- package/dist/cli/commands/refresh.d.ts +29 -0
- package/dist/cli/commands/refresh.js +70 -0
- package/dist/cli/commands/render.d.ts +14 -0
- package/dist/cli/commands/render.js +27 -0
- package/dist/cli/commands/schema.d.ts +2 -0
- package/dist/cli/commands/schema.js +9 -0
- package/dist/cli/commands/search.d.ts +9 -0
- package/dist/cli/commands/search.js +20 -0
- package/dist/cli/commands/update.d.ts +23 -0
- package/dist/cli/commands/update.js +69 -0
- package/dist/cli/commands/validate.d.ts +21 -0
- package/dist/cli/commands/validate.js +44 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +157 -0
- package/dist/cli/io.d.ts +4 -0
- package/dist/cli/io.js +25 -0
- package/dist/cli/managed-files.d.ts +35 -0
- package/dist/cli/managed-files.js +80 -0
- package/dist/cli/quartz-runner.d.ts +22 -0
- package/dist/cli/quartz-runner.js +76 -0
- package/dist/cli/quartz-setup.d.ts +10 -0
- package/dist/cli/quartz-setup.js +59 -0
- package/dist/cli/vault-path.d.ts +7 -0
- package/dist/cli/vault-path.js +44 -0
- package/dist/core/config/build-variants.d.ts +5 -0
- package/dist/core/config/build-variants.js +13 -0
- package/dist/core/config/index.d.ts +4 -0
- package/dist/core/config/index.js +4 -0
- package/dist/core/config/load.d.ts +4 -0
- package/dist/core/config/load.js +25 -0
- package/dist/core/config/schema.d.ts +229 -0
- package/dist/core/config/schema.js +23 -0
- package/dist/core/config/validate-options.d.ts +4 -0
- package/dist/core/config/validate-options.js +4 -0
- package/dist/core/graph/build.d.ts +8 -0
- package/dist/core/graph/build.js +42 -0
- package/dist/core/graph/causal.d.ts +22 -0
- package/dist/core/graph/causal.js +106 -0
- package/dist/core/graph/dating.d.ts +18 -0
- package/dist/core/graph/dating.js +30 -0
- package/dist/core/graph/entities.d.ts +13 -0
- package/dist/core/graph/entities.js +22 -0
- package/dist/core/graph/index.d.ts +7 -0
- package/dist/core/graph/index.js +7 -0
- package/dist/core/graph/queries.d.ts +3 -0
- package/dist/core/graph/queries.js +8 -0
- package/dist/core/graph/types.d.ts +14 -0
- package/dist/core/graph/types.js +1 -0
- package/dist/core/graph/versioning.d.ts +15 -0
- package/dist/core/graph/versioning.js +50 -0
- package/dist/core/id.d.ts +3 -0
- package/dist/core/id.js +9 -0
- package/dist/core/lint/after-loops.d.ts +15 -0
- package/dist/core/lint/after-loops.js +34 -0
- package/dist/core/lint/dangling-links.d.ts +9 -0
- package/dist/core/lint/dangling-links.js +46 -0
- package/dist/core/lint/events-without-entities.d.ts +4 -0
- package/dist/core/lint/events-without-entities.js +9 -0
- package/dist/core/lint/forked-versions.d.ts +14 -0
- package/dist/core/lint/forked-versions.js +29 -0
- package/dist/core/lint/index.d.ts +6 -0
- package/dist/core/lint/index.js +6 -0
- package/dist/core/lint/lint.d.ts +24 -0
- package/dist/core/lint/lint.js +50 -0
- package/dist/core/lint/target-types.d.ts +9 -0
- package/dist/core/lint/target-types.js +47 -0
- package/dist/core/query/dates.d.ts +70 -0
- package/dist/core/query/dates.js +43 -0
- package/dist/core/query/index.d.ts +2 -0
- package/dist/core/query/index.js +2 -0
- package/dist/core/query/types.d.ts +75 -0
- package/dist/core/query/types.js +12 -0
- package/dist/core/schema/describe.d.ts +8 -0
- package/dist/core/schema/describe.js +51 -0
- package/dist/core/schema/fields.d.ts +249 -0
- package/dist/core/schema/fields.js +43 -0
- package/dist/core/schema/index.d.ts +4 -0
- package/dist/core/schema/index.js +4 -0
- package/dist/core/schema/validate.d.ts +22 -0
- package/dist/core/schema/validate.js +34 -0
- package/dist/core/schema/wikilink.d.ts +8 -0
- package/dist/core/schema/wikilink.js +23 -0
- package/dist/core/search/build.d.ts +11 -0
- package/dist/core/search/build.js +26 -0
- package/dist/core/search/columns.d.ts +9 -0
- package/dist/core/search/columns.js +14 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/search.d.ts +3 -0
- package/dist/core/search/search.js +35 -0
- package/dist/core/search/types.d.ts +18 -0
- package/dist/core/search/types.js +1 -0
- package/dist/core/vault/index.d.ts +6 -0
- package/dist/core/vault/index.js +6 -0
- package/dist/core/vault/parse.d.ts +8 -0
- package/dist/core/vault/parse.js +11 -0
- package/dist/core/vault/read.d.ts +13 -0
- package/dist/core/vault/read.js +62 -0
- package/dist/core/vault/relink.d.ts +10 -0
- package/dist/core/vault/relink.js +33 -0
- package/dist/core/vault/serialize.d.ts +4 -0
- package/dist/core/vault/serialize.js +4 -0
- package/dist/core/vault/types.d.ts +5 -0
- package/dist/core/vault/types.js +1 -0
- package/dist/core/vault/write.d.ts +18 -0
- package/dist/core/vault/write.js +37 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/render-pipeline/build.d.ts +9 -0
- package/dist/render-pipeline/build.js +45 -0
- package/dist/render-pipeline/filter.d.ts +21 -0
- package/dist/render-pipeline/filter.js +80 -0
- package/dist/render-pipeline/index.d.ts +10 -0
- package/dist/render-pipeline/index.js +10 -0
- package/dist/render-pipeline/merge.d.ts +11 -0
- package/dist/render-pipeline/merge.js +21 -0
- package/dist/render-pipeline/quartz-compat.d.ts +1 -0
- package/dist/render-pipeline/quartz-compat.js +18 -0
- package/dist/render-pipeline/rewrite.d.ts +8 -0
- package/dist/render-pipeline/rewrite.js +23 -0
- package/dist/render-pipeline/slugs.d.ts +7 -0
- package/dist/render-pipeline/slugs.js +30 -0
- package/dist/render-pipeline/sync.d.ts +9 -0
- package/dist/render-pipeline/sync.js +18 -0
- package/dist/render-pipeline/types.d.ts +8 -0
- package/dist/render-pipeline/types.js +1 -0
- package/dist/render-pipeline/write.d.ts +3 -0
- package/dist/render-pipeline/write.js +12 -0
- package/package.json +46 -4
- package/skills/world-vault/SKILL.md +100 -0
- package/templates/AGENTS.md +11 -0
- package/templates/gitignore +5 -0
- package/templates/publish.yml +54 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function outgoing(graph, id, field) {
|
|
2
|
+
const edges = graph.edgesFrom.get(id) ?? [];
|
|
3
|
+
return field ? edges.filter((edge) => edge.field === field) : edges;
|
|
4
|
+
}
|
|
5
|
+
export function incoming(graph, id, field) {
|
|
6
|
+
const edges = graph.edgesTo.get(id) ?? [];
|
|
7
|
+
return field ? edges.filter((edge) => edge.field === field) : edges;
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
export interface GraphEdge {
|
|
3
|
+
/** The frontmatter field this edge came from (e.g. "location", "after", "supersedes"). */
|
|
4
|
+
field: string;
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
/** The link's display text, as written at link-creation time (SCHEMA.md rule 7). */
|
|
8
|
+
display: string;
|
|
9
|
+
}
|
|
10
|
+
export interface KnowledgeGraph {
|
|
11
|
+
nodes: Map<string, VaultFile>;
|
|
12
|
+
edgesFrom: Map<string, GraphEdge[]>;
|
|
13
|
+
edgesTo: Map<string, GraphEdge[]>;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { KnowledgeGraph } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every version of an entity, starting at its anchor id, oldest first
|
|
5
|
+
* (SCHEMA.md rule 8). This is what the wiki's merge step uses to render an
|
|
6
|
+
* entity's full history rather than just its latest version.
|
|
7
|
+
*
|
|
8
|
+
* Usually the versions form a single chain. If two versions supersede the same
|
|
9
|
+
* file (a fork — a story branch, or more often a version pointed at the wrong
|
|
10
|
+
* predecessor), all of them are still returned rather than silently dropping
|
|
11
|
+
* one: a version always comes after the one it supersedes, and versions that
|
|
12
|
+
* are ready at the same time are ordered by their `causedBy` event's causal
|
|
13
|
+
* position, then by id, so the result never depends on vault read order.
|
|
14
|
+
*/
|
|
15
|
+
export declare function versionHistory(graph: KnowledgeGraph, anchorId: string): VaultFile[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { incoming, outgoing } from "./queries.js";
|
|
2
|
+
import { topologicalOrder } from "./causal.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every version of an entity, starting at its anchor id, oldest first
|
|
5
|
+
* (SCHEMA.md rule 8). This is what the wiki's merge step uses to render an
|
|
6
|
+
* entity's full history rather than just its latest version.
|
|
7
|
+
*
|
|
8
|
+
* Usually the versions form a single chain. If two versions supersede the same
|
|
9
|
+
* file (a fork — a story branch, or more often a version pointed at the wrong
|
|
10
|
+
* predecessor), all of them are still returned rather than silently dropping
|
|
11
|
+
* one: a version always comes after the one it supersedes, and versions that
|
|
12
|
+
* are ready at the same time are ordered by their `causedBy` event's causal
|
|
13
|
+
* position, then by id, so the result never depends on vault read order.
|
|
14
|
+
*/
|
|
15
|
+
export function versionHistory(graph, anchorId) {
|
|
16
|
+
const anchor = graph.nodes.get(anchorId);
|
|
17
|
+
if (!anchor)
|
|
18
|
+
return [];
|
|
19
|
+
const history = [anchor];
|
|
20
|
+
const seen = new Set([anchorId]);
|
|
21
|
+
const ready = [];
|
|
22
|
+
const addSuccessors = (id) => {
|
|
23
|
+
for (const edge of incoming(graph, id, "supersedes")) {
|
|
24
|
+
if (seen.has(edge.from)) {
|
|
25
|
+
throw new Error(`Cycle detected in supersedes chain starting at ${anchorId}`);
|
|
26
|
+
}
|
|
27
|
+
seen.add(edge.from);
|
|
28
|
+
const file = graph.nodes.get(edge.from);
|
|
29
|
+
if (file)
|
|
30
|
+
ready.push(file);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
// Only needed to order a fork, so a plain chain never pays for (or depends on) the causal sort.
|
|
34
|
+
let causalIndex;
|
|
35
|
+
const rank = (file) => {
|
|
36
|
+
causalIndex ??= new Map(topologicalOrder(graph).map((id, i) => [id, i]));
|
|
37
|
+
const causedBy = outgoing(graph, file.id, "causedBy")[0];
|
|
38
|
+
return (causedBy && causalIndex.get(causedBy.to)) ?? Infinity;
|
|
39
|
+
};
|
|
40
|
+
addSuccessors(anchorId);
|
|
41
|
+
while (ready.length > 0) {
|
|
42
|
+
if (ready.length > 1) {
|
|
43
|
+
ready.sort((a, b) => rank(a) - rank(b) || (a.id < b.id ? -1 : 1));
|
|
44
|
+
}
|
|
45
|
+
const next = ready.shift();
|
|
46
|
+
history.push(next);
|
|
47
|
+
addSuccessors(next.id);
|
|
48
|
+
}
|
|
49
|
+
return history;
|
|
50
|
+
}
|
package/dist/core/id.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
/** Generates a new file identity (used as a vault file's filename, per SCHEMA.md rule 1). */
|
|
4
|
+
export function generateId() {
|
|
5
|
+
return uuidv4();
|
|
6
|
+
}
|
|
7
|
+
export function isUuid(value) {
|
|
8
|
+
return UUID_RE.test(value);
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/** The ids a file's `after` points at (valid wikilinks only). */
|
|
4
|
+
export declare function afterTargets(frontmatter: Record<string, unknown>): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Flags an event that's part of a loop in `after` (it would end up before
|
|
7
|
+
* itself). An error: unlike a fork, a loop is never meaningful, and it makes
|
|
8
|
+
* causal ordering — and with it the render — fail. Checks the file's own
|
|
9
|
+
* `after` as given (so an update is judged on its new content) against every
|
|
10
|
+
* other event's stored `after`. A file without an id is new: nothing links to
|
|
11
|
+
* it yet, so it can't be in a loop.
|
|
12
|
+
*/
|
|
13
|
+
export declare function findAfterLoops(file: Pick<VaultFile, "frontmatter"> & {
|
|
14
|
+
id?: string;
|
|
15
|
+
}, afterOf: Map<string, string[]>, titles: Map<string, string>): ValidationIssue[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isWikilink, parseWikilink } from "../schema/wikilink.js";
|
|
2
|
+
import { findAfterLoop } from "../graph/causal.js";
|
|
3
|
+
/** The ids a file's `after` points at (valid wikilinks only). */
|
|
4
|
+
export function afterTargets(frontmatter) {
|
|
5
|
+
const value = frontmatter.after;
|
|
6
|
+
if (!Array.isArray(value))
|
|
7
|
+
return [];
|
|
8
|
+
return value.filter(isWikilink).map((link) => parseWikilink(link).targetId);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Flags an event that's part of a loop in `after` (it would end up before
|
|
12
|
+
* itself). An error: unlike a fork, a loop is never meaningful, and it makes
|
|
13
|
+
* causal ordering — and with it the render — fail. Checks the file's own
|
|
14
|
+
* `after` as given (so an update is judged on its new content) against every
|
|
15
|
+
* other event's stored `after`. A file without an id is new: nothing links to
|
|
16
|
+
* it yet, so it can't be in a loop.
|
|
17
|
+
*/
|
|
18
|
+
export function findAfterLoops(file, afterOf, titles) {
|
|
19
|
+
if (!file.id || file.frontmatter.type !== "event")
|
|
20
|
+
return [];
|
|
21
|
+
const ownAfter = afterTargets(file.frontmatter);
|
|
22
|
+
const loop = findAfterLoop(file.id, (id) => (id === file.id ? ownAfter : (afterOf.get(id) ?? [])));
|
|
23
|
+
if (!loop)
|
|
24
|
+
return [];
|
|
25
|
+
const ownTitle = typeof file.frontmatter.title === "string" ? file.frontmatter.title : undefined;
|
|
26
|
+
const describe = (id) => `"${(id === file.id ? ownTitle : titles.get(id)) ?? "untitled"}" (${id})`;
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
path: "after",
|
|
30
|
+
message: `Loop in \`after\` — each event is after the next, so none can come first: ${loop.map(describe).join(" → ")}`,
|
|
31
|
+
severity: "error",
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/**
|
|
4
|
+
* Finds wikilinks — frontmatter or body — pointing at an id that doesn't exist
|
|
5
|
+
* anywhere in the vault. Checks body text too, not just frontmatter relations:
|
|
6
|
+
* a broken link is still bad to read even though body mentions don't count as
|
|
7
|
+
* relation edges (REQUIREMENTS.md's explicit-only relatedness rule).
|
|
8
|
+
*/
|
|
9
|
+
export declare function findDanglingLinks(file: Pick<VaultFile, "frontmatter" | "body">, knownIds: Set<string>): ValidationIssue[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isWikilink, parseWikilink } from "../schema/wikilink.js";
|
|
2
|
+
function extractFrontmatterLinks(value, path, out) {
|
|
3
|
+
if (typeof value === "string" && isWikilink(value)) {
|
|
4
|
+
out.push({ path, targetId: parseWikilink(value).targetId });
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
value.forEach((entry, i) => extractFrontmatterLinks(entry, `${path}[${i}]`, out));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const BODY_WIKILINK_RE = /\[\[([0-9a-f-]{36})\|[^\]]*\]\]/gi;
|
|
12
|
+
function extractBodyLinkTargets(body) {
|
|
13
|
+
return [...body.matchAll(BODY_WIKILINK_RE)].map((match) => match[1]);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Finds wikilinks — frontmatter or body — pointing at an id that doesn't exist
|
|
17
|
+
* anywhere in the vault. Checks body text too, not just frontmatter relations:
|
|
18
|
+
* a broken link is still bad to read even though body mentions don't count as
|
|
19
|
+
* relation edges (REQUIREMENTS.md's explicit-only relatedness rule).
|
|
20
|
+
*/
|
|
21
|
+
export function findDanglingLinks(file, knownIds) {
|
|
22
|
+
const issues = [];
|
|
23
|
+
const frontmatterLinks = [];
|
|
24
|
+
for (const [key, value] of Object.entries(file.frontmatter)) {
|
|
25
|
+
extractFrontmatterLinks(value, key, frontmatterLinks);
|
|
26
|
+
}
|
|
27
|
+
for (const link of frontmatterLinks) {
|
|
28
|
+
if (!knownIds.has(link.targetId)) {
|
|
29
|
+
issues.push({
|
|
30
|
+
path: link.path,
|
|
31
|
+
message: `Dangling link: no file with id "${link.targetId}" exists in the vault`,
|
|
32
|
+
severity: "error",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const targetId of extractBodyLinkTargets(file.body)) {
|
|
37
|
+
if (!knownIds.has(targetId)) {
|
|
38
|
+
issues.push({
|
|
39
|
+
path: "body",
|
|
40
|
+
message: `Dangling link: no file with id "${targetId}" exists in the vault`,
|
|
41
|
+
severity: "error",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return issues;
|
|
46
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/** Flags an event with no `entities` — SCHEMA.md's own example of a plausible lint rule. A warning, not an error: an event may be written before its entities exist. */
|
|
4
|
+
export declare function findEventsWithoutEntities(file: Pick<VaultFile, "frontmatter">): ValidationIssue[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Flags an event with no `entities` — SCHEMA.md's own example of a plausible lint rule. A warning, not an error: an event may be written before its entities exist. */
|
|
2
|
+
export function findEventsWithoutEntities(file) {
|
|
3
|
+
if (file.frontmatter.type !== "event")
|
|
4
|
+
return [];
|
|
5
|
+
const entities = file.frontmatter.entities;
|
|
6
|
+
if (Array.isArray(entities) && entities.length > 0)
|
|
7
|
+
return [];
|
|
8
|
+
return [{ path: "entities", message: "Event has no entities — consider tying it to at least one", severity: "warning" }];
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/** The id a file's `supersedes` points at, if it's a valid wikilink. */
|
|
4
|
+
export declare function supersedesTarget(frontmatter: Record<string, unknown>): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Flags a version that supersedes a file some other version already supersedes —
|
|
7
|
+
* a fork in the entity's version chain. A warning, not an error: the data model
|
|
8
|
+
* allows story branches (GD-1), but the v1 wiki renders a single story, so a fork
|
|
9
|
+
* is far more often a version pointed at an older predecessor than at the latest.
|
|
10
|
+
* `supersededBy` maps each file id to the ids of the vault files superseding it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function findForkedVersions(file: Pick<VaultFile, "frontmatter"> & {
|
|
13
|
+
id?: string;
|
|
14
|
+
}, supersededBy: Map<string, string[]>): ValidationIssue[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isWikilink, parseWikilink } from "../schema/wikilink.js";
|
|
2
|
+
/** The id a file's `supersedes` points at, if it's a valid wikilink. */
|
|
3
|
+
export function supersedesTarget(frontmatter) {
|
|
4
|
+
const value = frontmatter.supersedes;
|
|
5
|
+
return isWikilink(value) ? parseWikilink(value).targetId : undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Flags a version that supersedes a file some other version already supersedes —
|
|
9
|
+
* a fork in the entity's version chain. A warning, not an error: the data model
|
|
10
|
+
* allows story branches (GD-1), but the v1 wiki renders a single story, so a fork
|
|
11
|
+
* is far more often a version pointed at an older predecessor than at the latest.
|
|
12
|
+
* `supersededBy` maps each file id to the ids of the vault files superseding it.
|
|
13
|
+
*/
|
|
14
|
+
export function findForkedVersions(file, supersededBy) {
|
|
15
|
+
const target = supersedesTarget(file.frontmatter);
|
|
16
|
+
if (!target)
|
|
17
|
+
return [];
|
|
18
|
+
const others = (supersededBy.get(target) ?? []).filter((id) => id !== file.id);
|
|
19
|
+
if (others.length === 0)
|
|
20
|
+
return [];
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
path: "supersedes",
|
|
24
|
+
message: `Fork in the version chain: ${others.join(", ")} also supersede${others.length === 1 ? "s" : ""} ${target}. ` +
|
|
25
|
+
"A new version should normally supersede the entity's latest version.",
|
|
26
|
+
severity: "warning",
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/** Whole-vault facts some lint rules need. Build once per vault snapshot with createLintContext. */
|
|
4
|
+
export interface LintContext {
|
|
5
|
+
knownIds: Set<string>;
|
|
6
|
+
/** File id -> ids of the files whose `supersedes` points at it. */
|
|
7
|
+
supersededBy: Map<string, string[]>;
|
|
8
|
+
/** Event id -> the ids its `after` points at. */
|
|
9
|
+
afterOf: Map<string, string[]>;
|
|
10
|
+
/** File id -> its frontmatter `type`. */
|
|
11
|
+
types: Map<string, unknown>;
|
|
12
|
+
/** File id -> title, for readable messages. */
|
|
13
|
+
titles: Map<string, string>;
|
|
14
|
+
}
|
|
15
|
+
export declare function createLintContext(files: VaultFile[]): LintContext;
|
|
16
|
+
/**
|
|
17
|
+
* Runs every lint rule against one file. Pass the file's own `id` when it already
|
|
18
|
+
* exists in the vault (validate, update), so it isn't compared against itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function lintFile(file: Pick<VaultFile, "frontmatter" | "body"> & {
|
|
21
|
+
id?: string;
|
|
22
|
+
}, context: LintContext): ValidationIssue[];
|
|
23
|
+
/** Lints every file in a vault snapshot. Returns only files with at least one issue. */
|
|
24
|
+
export declare function lintVault(files: VaultFile[]): Map<string, ValidationIssue[]>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { findDanglingLinks } from "./dangling-links.js";
|
|
2
|
+
import { findEventsWithoutEntities } from "./events-without-entities.js";
|
|
3
|
+
import { findForkedVersions, supersedesTarget } from "./forked-versions.js";
|
|
4
|
+
import { findAfterLoops, afterTargets } from "./after-loops.js";
|
|
5
|
+
import { findWrongTargetTypes } from "./target-types.js";
|
|
6
|
+
export function createLintContext(files) {
|
|
7
|
+
const supersededBy = new Map();
|
|
8
|
+
const afterOf = new Map();
|
|
9
|
+
const titles = new Map();
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
const target = supersedesTarget(file.frontmatter);
|
|
12
|
+
if (target)
|
|
13
|
+
supersededBy.set(target, [...(supersededBy.get(target) ?? []), file.id]);
|
|
14
|
+
if (file.frontmatter.type === "event")
|
|
15
|
+
afterOf.set(file.id, afterTargets(file.frontmatter));
|
|
16
|
+
if (typeof file.frontmatter.title === "string")
|
|
17
|
+
titles.set(file.id, file.frontmatter.title);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
knownIds: new Set(files.map((file) => file.id)),
|
|
21
|
+
supersededBy,
|
|
22
|
+
afterOf,
|
|
23
|
+
types: new Map(files.map((file) => [file.id, file.frontmatter.type])),
|
|
24
|
+
titles,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Runs every lint rule against one file. Pass the file's own `id` when it already
|
|
29
|
+
* exists in the vault (validate, update), so it isn't compared against itself.
|
|
30
|
+
*/
|
|
31
|
+
export function lintFile(file, context) {
|
|
32
|
+
return [
|
|
33
|
+
...findDanglingLinks(file, context.knownIds),
|
|
34
|
+
...findEventsWithoutEntities(file),
|
|
35
|
+
...findForkedVersions(file, context.supersededBy),
|
|
36
|
+
...findAfterLoops(file, context.afterOf, context.titles),
|
|
37
|
+
...findWrongTargetTypes(file, context.types, context.titles),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
/** Lints every file in a vault snapshot. Returns only files with at least one issue. */
|
|
41
|
+
export function lintVault(files) {
|
|
42
|
+
const context = createLintContext(files);
|
|
43
|
+
const result = new Map();
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const issues = lintFile(file, context);
|
|
46
|
+
if (issues.length > 0)
|
|
47
|
+
result.set(file.id, issues);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { VaultFile } from "../vault/types.js";
|
|
2
|
+
import type { ValidationIssue } from "../schema/validate.js";
|
|
3
|
+
/**
|
|
4
|
+
* Flags each reserved relation whose target exists but is the wrong type. An
|
|
5
|
+
* error, since every case above silently breaks the wiki. Targets that don't
|
|
6
|
+
* exist at all are left to the dangling-link rule. `types` maps file id -> its
|
|
7
|
+
* frontmatter `type`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findWrongTargetTypes(file: Pick<VaultFile, "frontmatter">, types: Map<string, unknown>, titles: Map<string, string>): ValidationIssue[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { isWikilink, parseWikilink } from "../schema/wikilink.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reserved relation fields whose targets must be a specific type (SCHEMA.md),
|
|
4
|
+
* keyed by the type of the file holding the field. The schema only checks the
|
|
5
|
+
* values are wikilinks; a wrong-typed target passes that and then misbehaves
|
|
6
|
+
* at render time:
|
|
7
|
+
* - `after` -> non-event: causal ordering fails.
|
|
8
|
+
* - `entities` -> non-entity: the event isn't tied to any entity.
|
|
9
|
+
* - `causedBy` -> non-event: merge never shows the version (its "causing event" is never included).
|
|
10
|
+
* - `supersedes` -> non-entity: the version is unreachable from any anchor, so it never renders.
|
|
11
|
+
* On any other file type these field names are just free-form relations.
|
|
12
|
+
*/
|
|
13
|
+
const REQUIRED_TARGET_TYPES = {
|
|
14
|
+
event: { after: "event", entities: "entity" },
|
|
15
|
+
entity: { causedBy: "event", supersedes: "entity" },
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Flags each reserved relation whose target exists but is the wrong type. An
|
|
19
|
+
* error, since every case above silently breaks the wiki. Targets that don't
|
|
20
|
+
* exist at all are left to the dangling-link rule. `types` maps file id -> its
|
|
21
|
+
* frontmatter `type`.
|
|
22
|
+
*/
|
|
23
|
+
export function findWrongTargetTypes(file, types, titles) {
|
|
24
|
+
const fileType = file.frontmatter.type;
|
|
25
|
+
if (fileType !== "entity" && fileType !== "event")
|
|
26
|
+
return [];
|
|
27
|
+
const issues = [];
|
|
28
|
+
for (const [field, requiredType] of Object.entries(REQUIRED_TARGET_TYPES[fileType])) {
|
|
29
|
+
const value = file.frontmatter[field];
|
|
30
|
+
const entries = Array.isArray(value)
|
|
31
|
+
? value.map((entry, i) => [`${field}[${i}]`, entry])
|
|
32
|
+
: [[field, value]];
|
|
33
|
+
for (const [path, entry] of entries) {
|
|
34
|
+
if (!isWikilink(entry))
|
|
35
|
+
continue;
|
|
36
|
+
const { targetId } = parseWikilink(entry);
|
|
37
|
+
if (!types.has(targetId) || types.get(targetId) === requiredType)
|
|
38
|
+
continue;
|
|
39
|
+
issues.push({
|
|
40
|
+
path,
|
|
41
|
+
message: `\`${field}\` must point at an ${requiredType}, but "${titles.get(targetId) ?? "untitled"}" (${targetId}) isn't one`,
|
|
42
|
+
severity: "error",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return issues;
|
|
47
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const dateBoundSchema: z.ZodObject<{
|
|
3
|
+
year: z.ZodNumber;
|
|
4
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
year: number;
|
|
7
|
+
day?: number | undefined;
|
|
8
|
+
}, {
|
|
9
|
+
year: number;
|
|
10
|
+
day?: number | undefined;
|
|
11
|
+
}>;
|
|
12
|
+
export type DateBound = z.infer<typeof dateBoundSchema>;
|
|
13
|
+
export declare const dateRangeQuerySchema: z.ZodObject<{
|
|
14
|
+
from: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
year: z.ZodNumber;
|
|
16
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
year: number;
|
|
19
|
+
day?: number | undefined;
|
|
20
|
+
}, {
|
|
21
|
+
year: number;
|
|
22
|
+
day?: number | undefined;
|
|
23
|
+
}>>;
|
|
24
|
+
to: z.ZodOptional<z.ZodObject<{
|
|
25
|
+
year: z.ZodNumber;
|
|
26
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
year: number;
|
|
29
|
+
day?: number | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
year: number;
|
|
32
|
+
day?: number | undefined;
|
|
33
|
+
}>>;
|
|
34
|
+
}, "strip", z.ZodTypeAny, {
|
|
35
|
+
from?: {
|
|
36
|
+
year: number;
|
|
37
|
+
day?: number | undefined;
|
|
38
|
+
} | undefined;
|
|
39
|
+
to?: {
|
|
40
|
+
year: number;
|
|
41
|
+
day?: number | undefined;
|
|
42
|
+
} | undefined;
|
|
43
|
+
}, {
|
|
44
|
+
from?: {
|
|
45
|
+
year: number;
|
|
46
|
+
day?: number | undefined;
|
|
47
|
+
} | undefined;
|
|
48
|
+
to?: {
|
|
49
|
+
year: number;
|
|
50
|
+
day?: number | undefined;
|
|
51
|
+
} | undefined;
|
|
52
|
+
}>;
|
|
53
|
+
export type DateRangeQuery = z.infer<typeof dateRangeQuerySchema>;
|
|
54
|
+
/** An in-world date, as stored in an event's `date` field. */
|
|
55
|
+
export interface DateTuple {
|
|
56
|
+
year: number;
|
|
57
|
+
day: number;
|
|
58
|
+
}
|
|
59
|
+
/** Negative if a is earlier than b, positive if later, 0 if the same date. */
|
|
60
|
+
export declare function compareDates(a: DateTuple, b: DateTuple): number;
|
|
61
|
+
/** True if a dated value falls within the range's bounds (inclusive). Either bound is optional. */
|
|
62
|
+
export declare function isDateWithinRange(date: DateTuple, range: DateRangeQuery): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* True if a dated value is at or before the range's upper bound. Used for the
|
|
65
|
+
* entity/version inclusion gate — the lower bound never excludes entities
|
|
66
|
+
* (REQUIREMENTS.md: an entity introduced long ago can still be relevant now).
|
|
67
|
+
*/
|
|
68
|
+
export declare function isAtOrBeforeUpperBound(date: DateTuple, range: DateRangeQuery): boolean;
|
|
69
|
+
/** True if a dated value is at or after the range's lower bound (or the range has none). */
|
|
70
|
+
export declare function isAtOrAfterLowerBound(date: DateTuple, range: DateRangeQuery): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const dateBoundSchema = z.object({
|
|
3
|
+
year: z.number().int(),
|
|
4
|
+
day: z.number().int().optional(),
|
|
5
|
+
});
|
|
6
|
+
export const dateRangeQuerySchema = z.object({
|
|
7
|
+
from: dateBoundSchema.optional(),
|
|
8
|
+
to: dateBoundSchema.optional(),
|
|
9
|
+
});
|
|
10
|
+
function lowerTuple(bound) {
|
|
11
|
+
return { year: bound.year, day: bound.day ?? -Infinity };
|
|
12
|
+
}
|
|
13
|
+
function upperTuple(bound) {
|
|
14
|
+
return { year: bound.year, day: bound.day ?? Infinity };
|
|
15
|
+
}
|
|
16
|
+
/** Negative if a is earlier than b, positive if later, 0 if the same date. */
|
|
17
|
+
export function compareDates(a, b) {
|
|
18
|
+
return a.year !== b.year ? a.year - b.year : a.day - b.day;
|
|
19
|
+
}
|
|
20
|
+
/** True if a dated value falls within the range's bounds (inclusive). Either bound is optional. */
|
|
21
|
+
export function isDateWithinRange(date, range) {
|
|
22
|
+
if (range.from && compareDates(date, lowerTuple(range.from)) < 0)
|
|
23
|
+
return false;
|
|
24
|
+
if (range.to && compareDates(date, upperTuple(range.to)) > 0)
|
|
25
|
+
return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* True if a dated value is at or before the range's upper bound. Used for the
|
|
30
|
+
* entity/version inclusion gate — the lower bound never excludes entities
|
|
31
|
+
* (REQUIREMENTS.md: an entity introduced long ago can still be relevant now).
|
|
32
|
+
*/
|
|
33
|
+
export function isAtOrBeforeUpperBound(date, range) {
|
|
34
|
+
if (!range.to)
|
|
35
|
+
return true;
|
|
36
|
+
return compareDates(date, upperTuple(range.to)) <= 0;
|
|
37
|
+
}
|
|
38
|
+
/** True if a dated value is at or after the range's lower bound (or the range has none). */
|
|
39
|
+
export function isAtOrAfterLowerBound(date, range) {
|
|
40
|
+
if (!range.from)
|
|
41
|
+
return true;
|
|
42
|
+
return compareDates(date, lowerTuple(range.from)) >= 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A named build variant's filter query (PROJECT_CONFIG.md #3): tags a file must
|
|
4
|
+
* carry, and/or an event date range. This is both the runtime shape the render
|
|
5
|
+
* pipeline filters against and the shape a vault's world.config.json validates
|
|
6
|
+
* its `buildVariants` entries against — one definition, no separate DSL to parse.
|
|
7
|
+
*/
|
|
8
|
+
export declare const buildVariantQuerySchema: z.ZodObject<{
|
|
9
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
10
|
+
dateRange: z.ZodOptional<z.ZodObject<{
|
|
11
|
+
from: z.ZodOptional<z.ZodObject<{
|
|
12
|
+
year: z.ZodNumber;
|
|
13
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
}, "strip", z.ZodTypeAny, {
|
|
15
|
+
year: number;
|
|
16
|
+
day?: number | undefined;
|
|
17
|
+
}, {
|
|
18
|
+
year: number;
|
|
19
|
+
day?: number | undefined;
|
|
20
|
+
}>>;
|
|
21
|
+
to: z.ZodOptional<z.ZodObject<{
|
|
22
|
+
year: z.ZodNumber;
|
|
23
|
+
day: z.ZodOptional<z.ZodNumber>;
|
|
24
|
+
}, "strip", z.ZodTypeAny, {
|
|
25
|
+
year: number;
|
|
26
|
+
day?: number | undefined;
|
|
27
|
+
}, {
|
|
28
|
+
year: number;
|
|
29
|
+
day?: number | undefined;
|
|
30
|
+
}>>;
|
|
31
|
+
}, "strip", z.ZodTypeAny, {
|
|
32
|
+
from?: {
|
|
33
|
+
year: number;
|
|
34
|
+
day?: number | undefined;
|
|
35
|
+
} | undefined;
|
|
36
|
+
to?: {
|
|
37
|
+
year: number;
|
|
38
|
+
day?: number | undefined;
|
|
39
|
+
} | undefined;
|
|
40
|
+
}, {
|
|
41
|
+
from?: {
|
|
42
|
+
year: number;
|
|
43
|
+
day?: number | undefined;
|
|
44
|
+
} | undefined;
|
|
45
|
+
to?: {
|
|
46
|
+
year: number;
|
|
47
|
+
day?: number | undefined;
|
|
48
|
+
} | undefined;
|
|
49
|
+
}>>;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
tags?: string[] | undefined;
|
|
52
|
+
dateRange?: {
|
|
53
|
+
from?: {
|
|
54
|
+
year: number;
|
|
55
|
+
day?: number | undefined;
|
|
56
|
+
} | undefined;
|
|
57
|
+
to?: {
|
|
58
|
+
year: number;
|
|
59
|
+
day?: number | undefined;
|
|
60
|
+
} | undefined;
|
|
61
|
+
} | undefined;
|
|
62
|
+
}, {
|
|
63
|
+
tags?: string[] | undefined;
|
|
64
|
+
dateRange?: {
|
|
65
|
+
from?: {
|
|
66
|
+
year: number;
|
|
67
|
+
day?: number | undefined;
|
|
68
|
+
} | undefined;
|
|
69
|
+
to?: {
|
|
70
|
+
year: number;
|
|
71
|
+
day?: number | undefined;
|
|
72
|
+
} | undefined;
|
|
73
|
+
} | undefined;
|
|
74
|
+
}>;
|
|
75
|
+
export type BuildVariantQuery = z.infer<typeof buildVariantQuerySchema>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { dateRangeQuerySchema } from "./dates.js";
|
|
3
|
+
/**
|
|
4
|
+
* A named build variant's filter query (PROJECT_CONFIG.md #3): tags a file must
|
|
5
|
+
* carry, and/or an event date range. This is both the runtime shape the render
|
|
6
|
+
* pipeline filters against and the shape a vault's world.config.json validates
|
|
7
|
+
* its `buildVariants` entries against — one definition, no separate DSL to parse.
|
|
8
|
+
*/
|
|
9
|
+
export const buildVariantQuerySchema = z.object({
|
|
10
|
+
tags: z.array(z.string()).optional(),
|
|
11
|
+
dateRange: dateRangeQuerySchema.optional(),
|
|
12
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints the live, current schema: field names come straight from the zod
|
|
3
|
+
* schemas in fields.ts, so this can't structurally drift from what
|
|
4
|
+
* validateFrontmatter actually enforces. This is what the CLI's `schema`
|
|
5
|
+
* command exposes so Claude always sees the real, current rules — see
|
|
6
|
+
* claude-dev/PROJECT_STRUCTURE.md.
|
|
7
|
+
*/
|
|
8
|
+
export declare function describeSchema(entityTypeVocabulary?: string[]): string;
|