patram 0.0.2

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.
@@ -0,0 +1,113 @@
1
+ import { expect, it } from 'vitest';
2
+
3
+ import { parseClaims } from './parse-claims.js';
4
+
5
+ it('extracts markdown title, links and directives as neutral claims', () => {
6
+ const claims = parseClaims({
7
+ path: 'docs/patram.md',
8
+ source: createMarkdownSource(),
9
+ });
10
+
11
+ expect(claims).toEqual(createExpectedMarkdownClaims());
12
+ });
13
+
14
+ it('uses the first line as a plain markdown title', () => {
15
+ const claims = parseClaims({
16
+ path: 'docs/plain-title.md',
17
+ source: ['Patram Plain Title', '', 'Body text.'].join('\n'),
18
+ });
19
+
20
+ expect(claims).toEqual([
21
+ {
22
+ document_id: 'doc:docs/plain-title.md',
23
+ id: 'claim:doc:docs/plain-title.md:1',
24
+ origin: {
25
+ column: 1,
26
+ line: 1,
27
+ path: 'docs/plain-title.md',
28
+ },
29
+ type: 'document.title',
30
+ value: 'Patram Plain Title',
31
+ },
32
+ ]);
33
+ });
34
+
35
+ it('returns no claims for unsupported file types', () => {
36
+ const claims = parseClaims({
37
+ path: 'bin/patram.js',
38
+ source: 'console.log("Patram");',
39
+ });
40
+
41
+ expect(claims).toEqual([]);
42
+ });
43
+
44
+ it('returns no claims for extensionless files', () => {
45
+ const claims = parseClaims({
46
+ path: 'README',
47
+ source: '# Patram',
48
+ });
49
+
50
+ expect(claims).toEqual([]);
51
+ });
52
+
53
+ /**
54
+ * Create markdown input for parser tests.
55
+ *
56
+ * @returns {string}
57
+ */
58
+ function createMarkdownSource() {
59
+ return [
60
+ '# Patram',
61
+ '',
62
+ 'Read the [graph design](./graph-v0.md).',
63
+ 'Defined by: terms/patram.md',
64
+ ].join('\n');
65
+ }
66
+
67
+ /**
68
+ * Create the expected markdown claims.
69
+ *
70
+ * @returns {object[]}
71
+ */
72
+ function createExpectedMarkdownClaims() {
73
+ return [
74
+ {
75
+ document_id: 'doc:docs/patram.md',
76
+ id: 'claim:doc:docs/patram.md:1',
77
+ origin: {
78
+ column: 1,
79
+ line: 1,
80
+ path: 'docs/patram.md',
81
+ },
82
+ type: 'document.title',
83
+ value: 'Patram',
84
+ },
85
+ {
86
+ document_id: 'doc:docs/patram.md',
87
+ id: 'claim:doc:docs/patram.md:2',
88
+ origin: {
89
+ column: 10,
90
+ line: 3,
91
+ path: 'docs/patram.md',
92
+ },
93
+ type: 'markdown.link',
94
+ value: {
95
+ target: './graph-v0.md',
96
+ text: 'graph design',
97
+ },
98
+ },
99
+ {
100
+ document_id: 'doc:docs/patram.md',
101
+ id: 'claim:doc:docs/patram.md:3',
102
+ name: 'defined_by',
103
+ origin: {
104
+ column: 1,
105
+ line: 4,
106
+ path: 'docs/patram.md',
107
+ },
108
+ parser: 'markdown',
109
+ type: 'directive',
110
+ value: 'terms/patram.md',
111
+ },
112
+ ];
113
+ }
@@ -0,0 +1,27 @@
1
+ export interface ParseClaimsInput {
2
+ path: string;
3
+ source: string;
4
+ }
5
+
6
+ export interface ClaimOrigin {
7
+ path: string;
8
+ line: number;
9
+ column: number;
10
+ }
11
+
12
+ export interface PatramClaim {
13
+ document_id: string;
14
+ id: string;
15
+ name?: string;
16
+ origin: ClaimOrigin;
17
+ parser?: string;
18
+ type: string;
19
+ value: string | { target: string; text: string };
20
+ }
21
+
22
+ export type PatramClaimFields = Omit<
23
+ PatramClaim,
24
+ 'document_id' | 'id' | 'origin' | 'type'
25
+ > & {
26
+ origin?: ClaimOrigin;
27
+ };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @import { PatramConfig } from './patram-config.types.ts';
3
+ * @import { RefinementCtx } from 'zod';
4
+ */
5
+
6
+ import { z } from 'zod';
7
+
8
+ const KIND_NAME_SCHEMA = z.string().min(1);
9
+ const RELATION_NAME_SCHEMA = z.string().min(1);
10
+ const CLAIM_TYPE_SCHEMA = z.string().min(1);
11
+ const TARGET_SCHEMA = z.enum(['path']);
12
+
13
+ const kind_definition_schema = z
14
+ .object({
15
+ builtin: z.boolean().optional(),
16
+ label: z.string().min(1).optional(),
17
+ })
18
+ .strict();
19
+
20
+ const relation_definition_schema = z
21
+ .object({
22
+ builtin: z.boolean().optional(),
23
+ from: z.array(KIND_NAME_SCHEMA).min(1),
24
+ to: z.array(KIND_NAME_SCHEMA).min(1),
25
+ })
26
+ .strict();
27
+
28
+ const mapping_node_schema = z
29
+ .object({
30
+ field: z.string().min(1),
31
+ kind: KIND_NAME_SCHEMA,
32
+ })
33
+ .strict();
34
+
35
+ const mapping_emit_schema = z
36
+ .object({
37
+ relation: RELATION_NAME_SCHEMA,
38
+ target: TARGET_SCHEMA,
39
+ target_kind: KIND_NAME_SCHEMA,
40
+ })
41
+ .strict();
42
+
43
+ const mapping_definition_schema = z
44
+ .object({
45
+ emit: mapping_emit_schema.optional(),
46
+ node: mapping_node_schema.optional(),
47
+ })
48
+ .strict()
49
+ .superRefine(validateMappingDefinition);
50
+
51
+ export const patramConfigSchema = z
52
+ .object({
53
+ $schema: z.url().optional(),
54
+ kinds: z.record(KIND_NAME_SCHEMA, kind_definition_schema),
55
+ mappings: z.record(CLAIM_TYPE_SCHEMA, mapping_definition_schema),
56
+ relations: z.record(RELATION_NAME_SCHEMA, relation_definition_schema),
57
+ })
58
+ .strict()
59
+ .superRefine(validatePatramConfigReferences);
60
+
61
+ /**
62
+ * Parse and validate Patram JSON configuration.
63
+ *
64
+ * @param {unknown} config_json
65
+ * @returns {PatramConfig}
66
+ */
67
+ export function parsePatramConfig(config_json) {
68
+ return patramConfigSchema.parse(config_json);
69
+ }
70
+
71
+ /**
72
+ * @param {{ emit?: unknown, node?: unknown }} mapping_definition
73
+ * @param {RefinementCtx} refinement_context
74
+ */
75
+ function validateMappingDefinition(mapping_definition, refinement_context) {
76
+ if (mapping_definition.emit || mapping_definition.node) {
77
+ return;
78
+ }
79
+
80
+ refinement_context.addIssue({
81
+ code: 'custom',
82
+ message: 'Mapping must define at least one of "emit" or "node".',
83
+ });
84
+ }
85
+
86
+ /**
87
+ * @param {PatramConfig} config_json
88
+ * @param {RefinementCtx} refinement_context
89
+ */
90
+ function validatePatramConfigReferences(config_json, refinement_context) {
91
+ validateRelationKinds(config_json, refinement_context);
92
+ validateMappingKinds(config_json, refinement_context);
93
+ validateMappingRelations(config_json, refinement_context);
94
+ }
95
+
96
+ /**
97
+ * @param {PatramConfig} config_json
98
+ * @param {RefinementCtx} refinement_context
99
+ */
100
+ function validateRelationKinds(config_json, refinement_context) {
101
+ for (const [relation_name, relation_definition] of Object.entries(
102
+ config_json.relations,
103
+ )) {
104
+ validateReferencedKinds(
105
+ relation_definition.from,
106
+ config_json.kinds,
107
+ ['relations', relation_name, 'from'],
108
+ refinement_context,
109
+ );
110
+ validateReferencedKinds(
111
+ relation_definition.to,
112
+ config_json.kinds,
113
+ ['relations', relation_name, 'to'],
114
+ refinement_context,
115
+ );
116
+ }
117
+ }
118
+
119
+ /**
120
+ * @param {PatramConfig} config_json
121
+ * @param {RefinementCtx} refinement_context
122
+ */
123
+ function validateMappingKinds(config_json, refinement_context) {
124
+ for (const [mapping_name, mapping_definition] of Object.entries(
125
+ config_json.mappings,
126
+ )) {
127
+ if (mapping_definition.emit) {
128
+ validateReferencedKinds(
129
+ [mapping_definition.emit.target_kind],
130
+ config_json.kinds,
131
+ ['mappings', mapping_name, 'emit', 'target_kind'],
132
+ refinement_context,
133
+ );
134
+ }
135
+
136
+ if (mapping_definition.node) {
137
+ validateReferencedKinds(
138
+ [mapping_definition.node.kind],
139
+ config_json.kinds,
140
+ ['mappings', mapping_name, 'node', 'kind'],
141
+ refinement_context,
142
+ );
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * @param {PatramConfig} config_json
149
+ * @param {RefinementCtx} refinement_context
150
+ */
151
+ function validateMappingRelations(config_json, refinement_context) {
152
+ for (const [mapping_name, mapping_definition] of Object.entries(
153
+ config_json.mappings,
154
+ )) {
155
+ if (!mapping_definition.emit) {
156
+ continue;
157
+ }
158
+
159
+ if (config_json.relations[mapping_definition.emit.relation]) {
160
+ continue;
161
+ }
162
+
163
+ refinement_context.addIssue({
164
+ code: 'custom',
165
+ message: `Unknown relation "${mapping_definition.emit.relation}".`,
166
+ path: ['mappings', mapping_name, 'emit', 'relation'],
167
+ });
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {string[]} referenced_kinds
173
+ * @param {Record<string, unknown>} known_kinds
174
+ * @param {(string | number)[]} issue_path
175
+ * @param {RefinementCtx} refinement_context
176
+ */
177
+ function validateReferencedKinds(
178
+ referenced_kinds,
179
+ known_kinds,
180
+ issue_path,
181
+ refinement_context,
182
+ ) {
183
+ for (const referenced_kind of referenced_kinds) {
184
+ if (known_kinds[referenced_kind]) {
185
+ continue;
186
+ }
187
+
188
+ refinement_context.addIssue({
189
+ code: 'custom',
190
+ message: `Unknown kind "${referenced_kind}".`,
191
+ path: issue_path,
192
+ });
193
+ }
194
+ }
@@ -0,0 +1,147 @@
1
+ import { expect, it } from 'vitest';
2
+
3
+ import graph_v0_config from '../docs/graph-v0.config.json' with { type: 'json' };
4
+ import { parsePatramConfig } from './patram-config.js';
5
+
6
+ it('parses the documented v0 config example', () => {
7
+ const config_json = parsePatramConfig(graph_v0_config);
8
+
9
+ expect(config_json.kinds.term.label).toBe('Term');
10
+ expect(config_json.mappings['markdown.directive.defined_by'].emit).toEqual({
11
+ relation: 'defines',
12
+ target: 'path',
13
+ target_kind: 'term',
14
+ });
15
+ });
16
+
17
+ it('rejects mappings that reference unknown relations', () => {
18
+ const issues = getIssues(createMissingRelationConfig());
19
+
20
+ expect(issues).toContainEqual(
21
+ expect.objectContaining({
22
+ message: 'Unknown relation "missing_relation".',
23
+ path: ['mappings', 'markdown.link', 'emit', 'relation'],
24
+ }),
25
+ );
26
+ });
27
+
28
+ it('rejects mappings that reference unknown kinds', () => {
29
+ const issues = getIssues(createUnknownKindConfig());
30
+
31
+ expect(issues).toContainEqual(
32
+ expect.objectContaining({
33
+ message: 'Unknown kind "term".',
34
+ path: [
35
+ 'mappings',
36
+ 'markdown.directive.defined_by',
37
+ 'emit',
38
+ 'target_kind',
39
+ ],
40
+ }),
41
+ );
42
+ });
43
+
44
+ it('rejects mappings without semantic output', () => {
45
+ const issues = getIssues(createEmptyMappingConfig());
46
+
47
+ expect(issues).toContainEqual(
48
+ expect.objectContaining({
49
+ message: 'Mapping must define at least one of "emit" or "node".',
50
+ path: ['mappings', 'document.title'],
51
+ }),
52
+ );
53
+ });
54
+
55
+ /**
56
+ * Create a config with an unknown relation reference.
57
+ *
58
+ * @returns {object}
59
+ */
60
+ function createMissingRelationConfig() {
61
+ return {
62
+ kinds: {
63
+ document: {
64
+ builtin: true,
65
+ },
66
+ },
67
+ mappings: {
68
+ 'markdown.link': {
69
+ emit: {
70
+ relation: 'missing_relation',
71
+ target: 'path',
72
+ target_kind: 'document',
73
+ },
74
+ },
75
+ },
76
+ relations: {},
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Create a config with an unknown kind reference.
82
+ *
83
+ * @returns {object}
84
+ */
85
+ function createUnknownKindConfig() {
86
+ return {
87
+ kinds: {
88
+ document: {
89
+ builtin: true,
90
+ },
91
+ },
92
+ mappings: {
93
+ 'markdown.directive.defined_by': {
94
+ emit: {
95
+ relation: 'links_to',
96
+ target: 'path',
97
+ target_kind: 'term',
98
+ },
99
+ },
100
+ },
101
+ relations: {
102
+ links_to: {
103
+ from: ['document'],
104
+ to: ['document'],
105
+ },
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Create a config with an empty mapping definition.
112
+ *
113
+ * @returns {object}
114
+ */
115
+ function createEmptyMappingConfig() {
116
+ return {
117
+ kinds: {
118
+ document: {
119
+ builtin: true,
120
+ },
121
+ },
122
+ mappings: {
123
+ 'document.title': {},
124
+ },
125
+ relations: {},
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Parse config and return validation issues.
131
+ *
132
+ * @param {object} config_json
133
+ * @returns {unknown[]}
134
+ */
135
+ function getIssues(config_json) {
136
+ try {
137
+ parsePatramConfig(config_json);
138
+ } catch (error) {
139
+ if (error instanceof Error && 'issues' in error) {
140
+ return /** @type {{ issues: unknown[] }} */ (error).issues;
141
+ }
142
+
143
+ throw error;
144
+ }
145
+
146
+ throw new Error('Expected configuration parsing to fail.');
147
+ }
@@ -0,0 +1,33 @@
1
+ export interface KindDefinition {
2
+ builtin?: boolean;
3
+ label?: string;
4
+ }
5
+
6
+ export interface RelationDefinition {
7
+ builtin?: boolean;
8
+ from: string[];
9
+ to: string[];
10
+ }
11
+
12
+ export interface MappingNodeDefinition {
13
+ field: string;
14
+ kind: string;
15
+ }
16
+
17
+ export interface MappingEmitDefinition {
18
+ relation: string;
19
+ target: 'path';
20
+ target_kind: string;
21
+ }
22
+
23
+ export interface MappingDefinition {
24
+ emit?: MappingEmitDefinition;
25
+ node?: MappingNodeDefinition;
26
+ }
27
+
28
+ export interface PatramConfig {
29
+ $schema?: string;
30
+ kinds: Record<string, KindDefinition>;
31
+ mappings: Record<string, MappingDefinition>;
32
+ relations: Record<string, RelationDefinition>;
33
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "patram",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "files": [
6
+ "bin/",
7
+ "lib/"
8
+ ],
9
+ "bin": {
10
+ "patram": "./bin/patram.js"
11
+ },
12
+ "homepage": "https://github.com/mantoni/patram",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/mantoni/patram.git"
16
+ },
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "license": "MIT",
21
+ "scripts": {
22
+ "all": "npm run check:lint && npm run check:format && npm run check:types && npm run test:unit && npm run test:coverage && npm run check:dupes",
23
+ "check:dupes": "jscpd --min-tokens 100 --min-lines 6 --mode mild --threshold 0 --reporters console --gitignore .",
24
+ "check:format": "prettier --check .",
25
+ "check:lint": "eslint .",
26
+ "check:staged": "lint-staged --quiet",
27
+ "check:types": "tsc",
28
+ "postversion": "git push && git push --tags",
29
+ "preversion": "npm run all",
30
+ "prepare": "husky",
31
+ "test": "npm run test:unit && npm run test:coverage",
32
+ "test:coverage": "vitest run --coverage",
33
+ "test:unit": "vitest run",
34
+ "version": "node scripts/update-changelog.js && git add CHANGELOG.md package.json package-lock.json"
35
+ },
36
+ "lint-staged": {
37
+ "*.{js,ts,json,md}": "prettier --check",
38
+ "*.{js,ts}": [
39
+ "eslint",
40
+ "vitest related --run --passWithNoTests"
41
+ ]
42
+ },
43
+ "dependencies": {
44
+ "zod": "^4.3.6"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^10.0.1",
48
+ "@types/node": "^24.12.0",
49
+ "@vitest/coverage-v8": "^4.1.0",
50
+ "ansis": "^4.2.0",
51
+ "eslint": "^10.0.3",
52
+ "eslint-plugin-jsdoc": "^62.8.0",
53
+ "globals": "^17.4.0",
54
+ "husky": "^9.1.7",
55
+ "jscpd": "^4.0.8",
56
+ "lint-staged": "^16.2.6",
57
+ "prettier": "^3.5.3",
58
+ "slice-ansi": "^8.0.0",
59
+ "string-width": "^8.2.0",
60
+ "typescript": "^5.8.2",
61
+ "vitest": "^4.1.0",
62
+ "wrap-ansi": "^10.0.0"
63
+ }
64
+ }