@gitdocket/core 0.0.0 → 0.1.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/src/parse.ts ADDED
@@ -0,0 +1,200 @@
1
+ // Per-file parsing: markdown → AST (unified/remark) → frontmatter (yaml) +
2
+ // link graph. Reserved OKF filenames (index.md, log.md, overview.md) are structural, not
3
+ // concepts, and skip frontmatter validation entirely.
4
+
5
+ import remarkFrontmatter from "remark-frontmatter";
6
+ import remarkParse from "remark-parse";
7
+ import { unified } from "unified";
8
+ import { visit } from "unist-util-visit";
9
+ import { parse as parseYaml } from "yaml";
10
+ import type {
11
+ DecisionFrontmatter,
12
+ GenericFrontmatter,
13
+ Schemas,
14
+ WorkItemFrontmatter,
15
+ } from "./schema";
16
+
17
+ export interface Link {
18
+ /** Raw target as written: bundle-absolute (/specs/x.md), relative, or external URL. */
19
+ target: string;
20
+ internal: boolean;
21
+ }
22
+
23
+ export interface Diagnostic {
24
+ path: string;
25
+ message: string;
26
+ severity: "error" | "warning";
27
+ }
28
+
29
+ interface ConceptBase {
30
+ path: string;
31
+ links: Link[];
32
+ }
33
+
34
+ export interface WorkItem extends ConceptBase {
35
+ kind: "work";
36
+ fm: WorkItemFrontmatter;
37
+ /** Authored close result, when the conventional `# Outcome` section exists. */
38
+ outcome?: string;
39
+ }
40
+
41
+ export interface Decision extends ConceptBase {
42
+ kind: "decision";
43
+ fm: DecisionFrontmatter;
44
+ /** Conventional decision sections, kept as Markdown for shared summaries. */
45
+ context?: string;
46
+ decision?: string;
47
+ consequences?: string;
48
+ }
49
+
50
+ export interface GenericConcept extends ConceptBase {
51
+ kind: "generic";
52
+ fm: GenericFrontmatter;
53
+ }
54
+
55
+ export type Concept = WorkItem | Decision | GenericConcept;
56
+
57
+ const RESERVED = new Set(["index.md", "log.md", "overview.md"]);
58
+
59
+ export function isReserved(path: string): boolean {
60
+ const name = path.split("/").at(-1) ?? path;
61
+ return RESERVED.has(name);
62
+ }
63
+
64
+ const processor = unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]);
65
+
66
+ /** Extract one conventional level-one section without making it mandatory. */
67
+ export function markdownSection(
68
+ source: string,
69
+ heading: string,
70
+ ): string | undefined {
71
+ const lines = source.split(/\r?\n/);
72
+ const wanted = heading.trim().toLowerCase();
73
+ const start = lines.findIndex((line) => {
74
+ const match = /^#\s+(.+?)\s*$/.exec(line);
75
+ return match?.[1]?.trim().toLowerCase() === wanted;
76
+ });
77
+ if (start < 0) return undefined;
78
+ let end = lines.length;
79
+ for (let index = start + 1; index < lines.length; index += 1) {
80
+ if (/^#\s+/.test(lines[index] ?? "")) {
81
+ end = index;
82
+ break;
83
+ }
84
+ }
85
+ const body = lines
86
+ .slice(start + 1, end)
87
+ .join("\n")
88
+ .trim();
89
+ return body || undefined;
90
+ }
91
+
92
+ function extractLinks(tree: ReturnType<typeof processor.parse>): Link[] {
93
+ const links: Link[] = [];
94
+ visit(tree, "link", (node: { url?: string }) => {
95
+ if (!node.url) return;
96
+ links.push({
97
+ target: node.url,
98
+ internal: !/^[a-z][a-z0-9+.-]*:/i.test(node.url),
99
+ });
100
+ });
101
+ return links;
102
+ }
103
+
104
+ export function parseConcept(
105
+ path: string,
106
+ source: string,
107
+ schemas: Schemas,
108
+ ): { concept?: Concept; diagnostics: Diagnostic[] } {
109
+ const diagnostics: Diagnostic[] = [];
110
+ const tree = processor.parse(source);
111
+ const links = extractLinks(tree);
112
+
113
+ if (isReserved(path)) return { diagnostics };
114
+
115
+ const fmNode = tree.children[0];
116
+ if (fmNode?.type !== "yaml") {
117
+ diagnostics.push({
118
+ path,
119
+ message: "missing YAML frontmatter",
120
+ severity: "error",
121
+ });
122
+ return { diagnostics };
123
+ }
124
+
125
+ let raw: unknown;
126
+ try {
127
+ raw = parseYaml(fmNode.value);
128
+ } catch (error) {
129
+ diagnostics.push({
130
+ path,
131
+ message: `invalid YAML: ${String(error)}`,
132
+ severity: "error",
133
+ });
134
+ return { diagnostics };
135
+ }
136
+ if (typeof raw !== "object" || raw === null) {
137
+ diagnostics.push({
138
+ path,
139
+ message: "frontmatter is not a mapping",
140
+ severity: "error",
141
+ });
142
+ return { diagnostics };
143
+ }
144
+
145
+ const type = (raw as Record<string, unknown>).type;
146
+ if (typeof type !== "string" || type.length === 0) {
147
+ diagnostics.push({
148
+ path,
149
+ message: "missing required `type` field (OKF)",
150
+ severity: "error",
151
+ });
152
+ return { diagnostics };
153
+ }
154
+
155
+ const pick = () => {
156
+ if (type === "Task" || type === "Epic")
157
+ return { kind: "work" as const, schema: schemas.workItem };
158
+ if (type === "Decision")
159
+ return { kind: "decision" as const, schema: schemas.decision };
160
+ return { kind: "generic" as const, schema: schemas.generic };
161
+ };
162
+ const { kind, schema } = pick();
163
+
164
+ const result = schema.safeParse(raw);
165
+ if (!result.success) {
166
+ for (const issue of result.error.issues) {
167
+ diagnostics.push({
168
+ path,
169
+ message: `${issue.path.join(".") || "frontmatter"}: ${issue.message}`,
170
+ severity: "error",
171
+ });
172
+ }
173
+ return { diagnostics };
174
+ }
175
+
176
+ const sections =
177
+ kind === "work"
178
+ ? { outcome: markdownSection(source, "Outcome") }
179
+ : kind === "decision"
180
+ ? {
181
+ context: markdownSection(source, "Context"),
182
+ decision: markdownSection(source, "Decision"),
183
+ consequences: markdownSection(source, "Consequences"),
184
+ }
185
+ : {};
186
+ const presentSections = Object.fromEntries(
187
+ Object.entries(sections).filter(([, value]) => value !== undefined),
188
+ );
189
+
190
+ return {
191
+ concept: {
192
+ path,
193
+ links,
194
+ kind,
195
+ fm: result.data,
196
+ ...presentSections,
197
+ } as Concept,
198
+ diagnostics,
199
+ };
200
+ }