@descent-vtt/spec-graph 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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +627 -0
  3. package/bin/spec-graph.js +24 -0
  4. package/dist/baseline.d.ts +93 -0
  5. package/dist/baseline.js +187 -0
  6. package/dist/baseline.js.map +1 -0
  7. package/dist/cli.d.ts +66 -0
  8. package/dist/cli.js +565 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +66 -0
  11. package/dist/config.js +183 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/directives.d.ts +56 -0
  14. package/dist/directives.js +125 -0
  15. package/dist/directives.js.map +1 -0
  16. package/dist/extract.d.ts +116 -0
  17. package/dist/extract.js +1043 -0
  18. package/dist/extract.js.map +1 -0
  19. package/dist/glob.d.ts +73 -0
  20. package/dist/glob.js +284 -0
  21. package/dist/glob.js.map +1 -0
  22. package/dist/graph.d.ts +55 -0
  23. package/dist/graph.js +201 -0
  24. package/dist/graph.js.map +1 -0
  25. package/dist/identity.d.ts +87 -0
  26. package/dist/identity.js +294 -0
  27. package/dist/identity.js.map +1 -0
  28. package/dist/index.d.ts +63 -0
  29. package/dist/index.js +45 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/lifecycle.d.ts +57 -0
  32. package/dist/lifecycle.js +258 -0
  33. package/dist/lifecycle.js.map +1 -0
  34. package/dist/markdown.d.ts +152 -0
  35. package/dist/markdown.js +820 -0
  36. package/dist/markdown.js.map +1 -0
  37. package/dist/paths.d.ts +22 -0
  38. package/dist/paths.js +58 -0
  39. package/dist/paths.js.map +1 -0
  40. package/dist/report.d.ts +89 -0
  41. package/dist/report.js +326 -0
  42. package/dist/report.js.map +1 -0
  43. package/dist/resolve.d.ts +61 -0
  44. package/dist/resolve.js +340 -0
  45. package/dist/resolve.js.map +1 -0
  46. package/dist/rules.d.ts +61 -0
  47. package/dist/rules.js +557 -0
  48. package/dist/rules.js.map +1 -0
  49. package/dist/runner.d.ts +108 -0
  50. package/dist/runner.js +186 -0
  51. package/dist/runner.js.map +1 -0
  52. package/dist/sections.d.ts +94 -0
  53. package/dist/sections.js +372 -0
  54. package/dist/sections.js.map +1 -0
  55. package/dist/select.d.ts +96 -0
  56. package/dist/select.js +413 -0
  57. package/dist/select.js.map +1 -0
  58. package/dist/source.d.ts +43 -0
  59. package/dist/source.js +109 -0
  60. package/dist/source.js.map +1 -0
  61. package/dist/state.d.ts +45 -0
  62. package/dist/state.js +330 -0
  63. package/dist/state.js.map +1 -0
  64. package/dist/types.d.ts +294 -0
  65. package/dist/types.js +52 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/yaml.d.ts +37 -0
  68. package/dist/yaml.js +240 -0
  69. package/dist/yaml.js.map +1 -0
  70. package/package.json +71 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Repository configuration.
3
+ *
4
+ * Three ADRs left the same open question: `--ignore-ref` patterns, include
5
+ * globs and rule severities all belong to a repository rather than to whoever
6
+ * typed the command, and an npm script was doing that job by accumulating
7
+ * flags. A CI invocation carrying eight `--ignore-ref` arguments is a
8
+ * configuration file that has not admitted what it is.
9
+ *
10
+ * The format is JSON, parsed with `JSON.parse`. That is not a shortcut - it is
11
+ * the reason this file exists at all. spec-graph has no runtime dependencies,
12
+ * and a YAML or TOML reader would be the largest thing in the package, to read
13
+ * a file with nine keys in it.
14
+ *
15
+ * Every key mirrors a flag, and a flag always wins. Configuration is where a
16
+ * team writes down what is true of the repository; a flag is where somebody
17
+ * overrides it for one run.
18
+ */
19
+ import type { RuleId, Severity } from './types.js';
20
+ /** File names looked for, in order. The first that exists is the one used. */
21
+ export declare const CONFIG_FILES: readonly string[];
22
+ /** The `package.json` key read when no config file is present. */
23
+ export declare const CONFIG_PACKAGE_KEY = "spec-graph";
24
+ export interface SpecGraphConfig {
25
+ readonly patterns?: readonly string[] | undefined;
26
+ readonly ignore?: readonly string[] | undefined;
27
+ readonly ignoreReferences?: readonly string[] | undefined;
28
+ /**
29
+ * Families a bare identifier in prose may name.
30
+ *
31
+ * When set, `ADR-0099` is only ever read as a citation if `ADR` is listed.
32
+ * Everything else in prose stays prose.
33
+ */
34
+ readonly families?: readonly string[] | undefined;
35
+ /** Families that are never citations, whatever else the corpus contains. */
36
+ readonly ignoreFamilies?: readonly string[] | undefined;
37
+ /**
38
+ * Files that are logs of what was decided rather than decisions themselves.
39
+ *
40
+ * Journals, changelogs, minutes. Their links are still checked; their
41
+ * obligations and lifecycle are not. See ADR-0011.
42
+ */
43
+ readonly historyPatterns?: readonly string[] | undefined;
44
+ /** Path to the accepted-debt baseline, relative to the root. See ADR-0012. */
45
+ readonly baseline?: string | undefined;
46
+ readonly severities?: Partial<Record<RuleId, Severity>> | undefined;
47
+ readonly strict?: boolean | undefined;
48
+ readonly maxRelated?: number | undefined;
49
+ }
50
+ export interface LoadedConfig {
51
+ readonly config: SpecGraphConfig;
52
+ /** Where it came from, for `--verbose` and for error messages. */
53
+ readonly source: string | null;
54
+ /** Keys that were present but unusable. Reported, never silently dropped. */
55
+ readonly problems: readonly string[];
56
+ }
57
+ /**
58
+ * Reads configuration from a directory.
59
+ *
60
+ * Never throws. A malformed config is reported as a problem and the run
61
+ * continues with defaults: a broken config file should not stop a team seeing
62
+ * the findings it was going to show them anyway.
63
+ */
64
+ export declare function loadConfig(root: string, read?: (path: string) => string): LoadedConfig;
65
+ /** Parses config text. Exported so the shape can be tested without a disk. */
66
+ export declare function parseConfig(raw: string, source: string): LoadedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Repository configuration.
3
+ *
4
+ * Three ADRs left the same open question: `--ignore-ref` patterns, include
5
+ * globs and rule severities all belong to a repository rather than to whoever
6
+ * typed the command, and an npm script was doing that job by accumulating
7
+ * flags. A CI invocation carrying eight `--ignore-ref` arguments is a
8
+ * configuration file that has not admitted what it is.
9
+ *
10
+ * The format is JSON, parsed with `JSON.parse`. That is not a shortcut - it is
11
+ * the reason this file exists at all. spec-graph has no runtime dependencies,
12
+ * and a YAML or TOML reader would be the largest thing in the package, to read
13
+ * a file with nine keys in it.
14
+ *
15
+ * Every key mirrors a flag, and a flag always wins. Configuration is where a
16
+ * team writes down what is true of the repository; a flag is where somebody
17
+ * overrides it for one run.
18
+ */
19
+ import { readFileSync } from 'node:fs';
20
+ import { RULE_IDS } from './rules.js';
21
+ /** File names looked for, in order. The first that exists is the one used. */
22
+ export const CONFIG_FILES = Object.freeze([
23
+ '.spec-graph.json',
24
+ 'spec-graph.config.json',
25
+ ]);
26
+ /** The `package.json` key read when no config file is present. */
27
+ export const CONFIG_PACKAGE_KEY = 'spec-graph';
28
+ const EMPTY = { config: {}, source: null, problems: [] };
29
+ /**
30
+ * Reads configuration from a directory.
31
+ *
32
+ * Never throws. A malformed config is reported as a problem and the run
33
+ * continues with defaults: a broken config file should not stop a team seeing
34
+ * the findings it was going to show them anyway.
35
+ */
36
+ export function loadConfig(root, read = defaultRead) {
37
+ for (const name of CONFIG_FILES) {
38
+ const raw = tryRead(read, `${root}/${name}`);
39
+ if (raw === null)
40
+ continue;
41
+ return parseConfig(raw, name);
42
+ }
43
+ const pkg = tryRead(read, `${root}/package.json`);
44
+ if (pkg === null)
45
+ return EMPTY;
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(pkg);
49
+ }
50
+ catch {
51
+ return EMPTY;
52
+ }
53
+ if (!isRecord(parsed))
54
+ return EMPTY;
55
+ const section = parsed[CONFIG_PACKAGE_KEY];
56
+ if (section === undefined)
57
+ return EMPTY;
58
+ if (!isRecord(section)) {
59
+ return { config: {}, source: `package.json`, problems: [`"${CONFIG_PACKAGE_KEY}" must be an object`] };
60
+ }
61
+ return readFields(section, `package.json#${CONFIG_PACKAGE_KEY}`);
62
+ }
63
+ /** Parses config text. Exported so the shape can be tested without a disk. */
64
+ export function parseConfig(raw, source) {
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(withoutBom(raw));
68
+ }
69
+ catch (error) {
70
+ return { config: {}, source, problems: [`${source} is not valid JSON: ${error.message}`] };
71
+ }
72
+ if (!isRecord(parsed))
73
+ return { config: {}, source, problems: [`${source} must contain a JSON object`] };
74
+ return readFields(parsed, source);
75
+ }
76
+ function readFields(raw, source) {
77
+ const problems = [];
78
+ const config = {};
79
+ const strings = (key) => {
80
+ const value = raw[key];
81
+ if (value === undefined)
82
+ return undefined;
83
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
84
+ problems.push(`${source}: "${key}" must be an array of strings`);
85
+ return undefined;
86
+ }
87
+ return value;
88
+ };
89
+ config.patterns = strings('patterns');
90
+ config.ignore = strings('ignore');
91
+ config.ignoreReferences = strings('ignoreReferences');
92
+ config.families = strings('families');
93
+ config.ignoreFamilies = strings('ignoreFamilies');
94
+ config.historyPatterns = strings('historyPatterns');
95
+ if (raw['baseline'] !== undefined) {
96
+ if (typeof raw['baseline'] !== 'string' || raw['baseline'].trim().length === 0) {
97
+ problems.push(`${source}: "baseline" must be a path`);
98
+ }
99
+ else {
100
+ config.baseline = raw['baseline'];
101
+ }
102
+ }
103
+ if (raw['strict'] !== undefined) {
104
+ if (typeof raw['strict'] !== 'boolean')
105
+ problems.push(`${source}: "strict" must be true or false`);
106
+ else
107
+ config.strict = raw['strict'];
108
+ }
109
+ if (raw['maxRelated'] !== undefined) {
110
+ const value = raw['maxRelated'];
111
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
112
+ problems.push(`${source}: "maxRelated" must be a non-negative whole number`);
113
+ }
114
+ else {
115
+ config.maxRelated = value;
116
+ }
117
+ }
118
+ if (raw['severities'] !== undefined) {
119
+ const value = raw['severities'];
120
+ if (!isRecord(value)) {
121
+ problems.push(`${source}: "severities" must be an object of rule to severity`);
122
+ }
123
+ else {
124
+ const severities = {};
125
+ for (const [rule, level] of Object.entries(value)) {
126
+ if (!RULE_IDS.includes(rule)) {
127
+ problems.push(`${source}: unknown rule "${rule}" in "severities"`);
128
+ continue;
129
+ }
130
+ if (level !== 'error' && level !== 'warn' && level !== 'info' && level !== 'off') {
131
+ problems.push(`${source}: "severities.${rule}" must be error, warn, info or off`);
132
+ continue;
133
+ }
134
+ severities[rule] = level;
135
+ }
136
+ config.severities = severities;
137
+ }
138
+ }
139
+ for (const key of Object.keys(raw)) {
140
+ if (!KNOWN_KEYS.has(key))
141
+ problems.push(`${source}: unknown key "${key}"`);
142
+ }
143
+ return { config, source, problems };
144
+ }
145
+ const KNOWN_KEYS = new Set([
146
+ 'patterns',
147
+ 'ignore',
148
+ 'ignoreReferences',
149
+ 'families',
150
+ 'ignoreFamilies',
151
+ 'historyPatterns',
152
+ 'baseline',
153
+ 'severities',
154
+ 'strict',
155
+ 'maxRelated',
156
+ // Tolerated so an editor can be pointed at a schema without being told off.
157
+ '$schema',
158
+ ]);
159
+ function isRecord(value) {
160
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
161
+ }
162
+ function tryRead(read, path) {
163
+ try {
164
+ return withoutBom(read(path));
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ /**
171
+ * Drops a leading byte-order mark.
172
+ *
173
+ * `JSON.parse` rejects one, and several Windows editors write one by default -
174
+ * a config that silently stops applying because of an invisible first character
175
+ * is the worst kind of configuration bug.
176
+ */
177
+ function withoutBom(text) {
178
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
179
+ }
180
+ function defaultRead(path) {
181
+ return readFileSync(path, 'utf8');
182
+ }
183
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Repository configuration.\n *\n * Three ADRs left the same open question: `--ignore-ref` patterns, include\n * globs and rule severities all belong to a repository rather than to whoever\n * typed the command, and an npm script was doing that job by accumulating\n * flags. A CI invocation carrying eight `--ignore-ref` arguments is a\n * configuration file that has not admitted what it is.\n *\n * The format is JSON, parsed with `JSON.parse`. That is not a shortcut - it is\n * the reason this file exists at all. spec-graph has no runtime dependencies,\n * and a YAML or TOML reader would be the largest thing in the package, to read\n * a file with nine keys in it.\n *\n * Every key mirrors a flag, and a flag always wins. Configuration is where a\n * team writes down what is true of the repository; a flag is where somebody\n * overrides it for one run.\n */\n\nimport { readFileSync } from 'node:fs';\n\nimport { RULE_IDS } from './rules.js';\nimport type { RuleId, Severity } from './types.js';\n\n/** File names looked for, in order. The first that exists is the one used. */\nexport const CONFIG_FILES: readonly string[] = Object.freeze([\n '.spec-graph.json',\n 'spec-graph.config.json',\n]);\n\n/** The `package.json` key read when no config file is present. */\nexport const CONFIG_PACKAGE_KEY = 'spec-graph';\n\nexport interface SpecGraphConfig {\n readonly patterns?: readonly string[] | undefined;\n readonly ignore?: readonly string[] | undefined;\n readonly ignoreReferences?: readonly string[] | undefined;\n /**\n * Families a bare identifier in prose may name.\n *\n * When set, `ADR-0099` is only ever read as a citation if `ADR` is listed.\n * Everything else in prose stays prose.\n */\n readonly families?: readonly string[] | undefined;\n /** Families that are never citations, whatever else the corpus contains. */\n readonly ignoreFamilies?: readonly string[] | undefined;\n /**\n * Files that are logs of what was decided rather than decisions themselves.\n *\n * Journals, changelogs, minutes. Their links are still checked; their\n * obligations and lifecycle are not. See ADR-0011.\n */\n readonly historyPatterns?: readonly string[] | undefined;\n /** Path to the accepted-debt baseline, relative to the root. See ADR-0012. */\n readonly baseline?: string | undefined;\n readonly severities?: Partial<Record<RuleId, Severity>> | undefined;\n readonly strict?: boolean | undefined;\n readonly maxRelated?: number | undefined;\n}\n\nexport interface LoadedConfig {\n readonly config: SpecGraphConfig;\n /** Where it came from, for `--verbose` and for error messages. */\n readonly source: string | null;\n /** Keys that were present but unusable. Reported, never silently dropped. */\n readonly problems: readonly string[];\n}\n\nconst EMPTY: LoadedConfig = { config: {}, source: null, problems: [] };\n\n/**\n * Reads configuration from a directory.\n *\n * Never throws. A malformed config is reported as a problem and the run\n * continues with defaults: a broken config file should not stop a team seeing\n * the findings it was going to show them anyway.\n */\nexport function loadConfig(root: string, read: (path: string) => string = defaultRead): LoadedConfig {\n for (const name of CONFIG_FILES) {\n const raw = tryRead(read, `${root}/${name}`);\n if (raw === null) continue;\n return parseConfig(raw, name);\n }\n\n const pkg = tryRead(read, `${root}/package.json`);\n if (pkg === null) return EMPTY;\n let parsed: unknown;\n try {\n parsed = JSON.parse(pkg);\n } catch {\n return EMPTY;\n }\n if (!isRecord(parsed)) return EMPTY;\n const section = parsed[CONFIG_PACKAGE_KEY];\n if (section === undefined) return EMPTY;\n if (!isRecord(section)) {\n return { config: {}, source: `package.json`, problems: [`\"${CONFIG_PACKAGE_KEY}\" must be an object`] };\n }\n return readFields(section, `package.json#${CONFIG_PACKAGE_KEY}`);\n}\n\n/** Parses config text. Exported so the shape can be tested without a disk. */\nexport function parseConfig(raw: string, source: string): LoadedConfig {\n let parsed: unknown;\n try {\n parsed = JSON.parse(withoutBom(raw));\n } catch (error) {\n return { config: {}, source, problems: [`${source} is not valid JSON: ${(error as Error).message}`] };\n }\n if (!isRecord(parsed)) return { config: {}, source, problems: [`${source} must contain a JSON object`] };\n return readFields(parsed, source);\n}\n\nfunction readFields(raw: Record<string, unknown>, source: string): LoadedConfig {\n const problems: string[] = [];\n const config: {\n -readonly [K in keyof SpecGraphConfig]: SpecGraphConfig[K];\n } = {};\n\n const strings = (key: keyof SpecGraphConfig): readonly string[] | undefined => {\n const value = raw[key];\n if (value === undefined) return undefined;\n if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {\n problems.push(`${source}: \"${key}\" must be an array of strings`);\n return undefined;\n }\n return value as string[];\n };\n\n config.patterns = strings('patterns');\n config.ignore = strings('ignore');\n config.ignoreReferences = strings('ignoreReferences');\n config.families = strings('families');\n config.ignoreFamilies = strings('ignoreFamilies');\n config.historyPatterns = strings('historyPatterns');\n\n if (raw['baseline'] !== undefined) {\n if (typeof raw['baseline'] !== 'string' || raw['baseline'].trim().length === 0) {\n problems.push(`${source}: \"baseline\" must be a path`);\n } else {\n config.baseline = raw['baseline'];\n }\n }\n\n if (raw['strict'] !== undefined) {\n if (typeof raw['strict'] !== 'boolean') problems.push(`${source}: \"strict\" must be true or false`);\n else config.strict = raw['strict'];\n }\n\n if (raw['maxRelated'] !== undefined) {\n const value = raw['maxRelated'];\n if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n problems.push(`${source}: \"maxRelated\" must be a non-negative whole number`);\n } else {\n config.maxRelated = value;\n }\n }\n\n if (raw['severities'] !== undefined) {\n const value = raw['severities'];\n if (!isRecord(value)) {\n problems.push(`${source}: \"severities\" must be an object of rule to severity`);\n } else {\n const severities: Partial<Record<RuleId, Severity>> = {};\n for (const [rule, level] of Object.entries(value)) {\n if (!RULE_IDS.includes(rule as RuleId)) {\n problems.push(`${source}: unknown rule \"${rule}\" in \"severities\"`);\n continue;\n }\n if (level !== 'error' && level !== 'warn' && level !== 'info' && level !== 'off') {\n problems.push(`${source}: \"severities.${rule}\" must be error, warn, info or off`);\n continue;\n }\n severities[rule as RuleId] = level;\n }\n config.severities = severities;\n }\n }\n\n for (const key of Object.keys(raw)) {\n if (!KNOWN_KEYS.has(key)) problems.push(`${source}: unknown key \"${key}\"`);\n }\n\n return { config, source, problems };\n}\n\nconst KNOWN_KEYS: ReadonlySet<string> = new Set([\n 'patterns',\n 'ignore',\n 'ignoreReferences',\n 'families',\n 'ignoreFamilies',\n 'historyPatterns',\n 'baseline',\n 'severities',\n 'strict',\n 'maxRelated',\n // Tolerated so an editor can be pointed at a schema without being told off.\n '$schema',\n]);\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\nfunction tryRead(read: (path: string) => string, path: string): string | null {\n try {\n return withoutBom(read(path));\n } catch {\n return null;\n }\n}\n\n/**\n * Drops a leading byte-order mark.\n *\n * `JSON.parse` rejects one, and several Windows editors write one by default -\n * a config that silently stops applying because of an invisible first character\n * is the worst kind of configuration bug.\n */\nfunction withoutBom(text: string): string {\n return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\nfunction defaultRead(path: string): string {\n return readFileSync(path, 'utf8');\n}\n"]}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Explicit annotations, written as HTML comments.
3
+ *
4
+ * Inference covers the overwhelming majority of real documents, but inference
5
+ * that cannot be overridden is a trap: the first time it guesses wrong about a
6
+ * document somebody cannot restructure, the whole tool gets switched off. These
7
+ * directives are the escape hatch, and they are HTML comments so they stay
8
+ * invisible in GitHub, MkDocs, Docusaurus and every other renderer.
9
+ *
10
+ * ```md
11
+ * <!-- @spec-node id="ADR-0007" status="accepted" aliases="adr-7, sharding" -->
12
+ * <!-- @spec-item id="shard-key" state="narrowed" -->
13
+ * <!-- @spec-edge kind="delegates-to" to="ADR-0011#scope" -->
14
+ * <!-- @spec-ignore -->
15
+ * ```
16
+ *
17
+ * A directive always wins over anything inferred, and says so in the report, so
18
+ * an override is visible rather than mysterious.
19
+ */
20
+ import type { HtmlComment } from './markdown.js';
21
+ export type DirectiveName = 'spec-node' | 'spec-item' | 'spec-edge' | 'spec-ignore' | 'spec-history';
22
+ export interface Attribute {
23
+ readonly value: string;
24
+ /** Absolute offset of the value text, quotes excluded. */
25
+ readonly start: number;
26
+ readonly end: number;
27
+ }
28
+ export interface Directive {
29
+ readonly name: DirectiveName;
30
+ readonly attributes: ReadonlyMap<string, Attribute>;
31
+ /** Offset of the enclosing comment. */
32
+ readonly start: number;
33
+ readonly end: number;
34
+ readonly line: number;
35
+ /** Attribute names that were written but are not part of this directive. */
36
+ readonly unknownAttributes: readonly string[];
37
+ }
38
+ /** Reads every spec-graph directive out of a document's HTML comments. */
39
+ export declare function parseDirectives(comments: readonly HtmlComment[]): Directive[];
40
+ /** Reads an attribute, trimmed. Returns `null` when absent or blank. */
41
+ export declare function attr(directive: Directive, name: string): Attribute | null;
42
+ /** Splits a comma- or whitespace-separated attribute into its parts. */
43
+ export declare function attrList(directive: Directive, name: string): string[];
44
+ /**
45
+ * Finds the directive that annotates a given region.
46
+ *
47
+ * A `@spec-item` directive may sit on the line above the item, on the same
48
+ * line, or inside the item body. All three read naturally in the source, so all
49
+ * three are accepted, with the closest preceding directive winning.
50
+ */
51
+ export declare function directiveFor(directives: readonly Directive[], name: DirectiveName, region: {
52
+ start: number;
53
+ end: number;
54
+ },
55
+ /** How far above the region a directive may sit and still apply. */
56
+ lookBehind: number): Directive | null;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Explicit annotations, written as HTML comments.
3
+ *
4
+ * Inference covers the overwhelming majority of real documents, but inference
5
+ * that cannot be overridden is a trap: the first time it guesses wrong about a
6
+ * document somebody cannot restructure, the whole tool gets switched off. These
7
+ * directives are the escape hatch, and they are HTML comments so they stay
8
+ * invisible in GitHub, MkDocs, Docusaurus and every other renderer.
9
+ *
10
+ * ```md
11
+ * <!-- @spec-node id="ADR-0007" status="accepted" aliases="adr-7, sharding" -->
12
+ * <!-- @spec-item id="shard-key" state="narrowed" -->
13
+ * <!-- @spec-edge kind="delegates-to" to="ADR-0011#scope" -->
14
+ * <!-- @spec-ignore -->
15
+ * ```
16
+ *
17
+ * A directive always wins over anything inferred, and says so in the report, so
18
+ * an override is visible rather than mysterious.
19
+ */
20
+ const KNOWN = new Set([
21
+ 'spec-node',
22
+ 'spec-item',
23
+ 'spec-edge',
24
+ 'spec-ignore',
25
+ 'spec-history',
26
+ ]);
27
+ const DIRECTIVE_HEAD = /^\s*@([a-z][a-z0-9-]*)/;
28
+ const ATTRIBUTE = /([A-Za-z_][A-Za-z0-9_-]*)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'<>]+)))?/g;
29
+ /** Attributes each directive understands. Anything else is reported, not ignored. */
30
+ const SCHEMA = {
31
+ 'spec-node': ['id', 'status', 'title', 'aliases', 'kind'],
32
+ // Takes no attributes: the file either is a record or it is not.
33
+ 'spec-history': [],
34
+ 'spec-item': ['id', 'state', 'title', 'owner', 'note'],
35
+ 'spec-edge': ['kind', 'to', 'from', 'reason'],
36
+ 'spec-ignore': ['reason'],
37
+ };
38
+ /** Reads every spec-graph directive out of a document's HTML comments. */
39
+ export function parseDirectives(comments) {
40
+ const out = [];
41
+ for (const comment of comments) {
42
+ const head = DIRECTIVE_HEAD.exec(comment.inner);
43
+ if (!head)
44
+ continue;
45
+ const name = head[1];
46
+ if (!KNOWN.has(name))
47
+ continue;
48
+ const directiveName = name;
49
+ const bodyStart = (head.index ?? 0) + head[0].length;
50
+ const body = comment.inner.slice(bodyStart);
51
+ const attributes = new Map();
52
+ const unknown = [];
53
+ const allowed = SCHEMA[directiveName];
54
+ ATTRIBUTE.lastIndex = 0;
55
+ for (let m = ATTRIBUTE.exec(body); m !== null; m = ATTRIBUTE.exec(body)) {
56
+ const key = m[1].toLowerCase();
57
+ const quoted = m[3] ?? m[4];
58
+ const bare = m[5];
59
+ // A bare attribute is a flag: `<!-- @spec-ignore reason -->`.
60
+ const value = quoted ?? bare ?? 'true';
61
+ const raw = m[2];
62
+ const valueOffset = raw === undefined
63
+ ? (m.index ?? 0) + m[0].length
64
+ : (m.index ?? 0) + m[0].indexOf(raw) + (quoted === undefined ? 0 : 1);
65
+ const start = comment.innerStart + bodyStart + valueOffset;
66
+ if (!allowed.includes(key))
67
+ unknown.push(key);
68
+ attributes.set(key, { value, start, end: start + value.length });
69
+ }
70
+ out.push({
71
+ name: directiveName,
72
+ attributes,
73
+ start: comment.start,
74
+ end: comment.end,
75
+ line: comment.line,
76
+ unknownAttributes: unknown,
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+ /** Reads an attribute, trimmed. Returns `null` when absent or blank. */
82
+ export function attr(directive, name) {
83
+ const found = directive.attributes.get(name);
84
+ if (!found)
85
+ return null;
86
+ const trimmed = found.value.trim();
87
+ if (trimmed.length === 0)
88
+ return null;
89
+ const offset = found.value.indexOf(trimmed);
90
+ return { value: trimmed, start: found.start + offset, end: found.start + offset + trimmed.length };
91
+ }
92
+ /** Splits a comma- or whitespace-separated attribute into its parts. */
93
+ export function attrList(directive, name) {
94
+ const found = attr(directive, name);
95
+ if (!found)
96
+ return [];
97
+ return found.value
98
+ .split(/[,;\s]+/)
99
+ .map((part) => part.trim())
100
+ .filter((part) => part.length > 0);
101
+ }
102
+ /**
103
+ * Finds the directive that annotates a given region.
104
+ *
105
+ * A `@spec-item` directive may sit on the line above the item, on the same
106
+ * line, or inside the item body. All three read naturally in the source, so all
107
+ * three are accepted, with the closest preceding directive winning.
108
+ */
109
+ export function directiveFor(directives, name, region,
110
+ /** How far above the region a directive may sit and still apply. */
111
+ lookBehind) {
112
+ let best = null;
113
+ for (const directive of directives) {
114
+ if (directive.name !== name)
115
+ continue;
116
+ const inside = directive.start >= region.start && directive.end <= region.end;
117
+ const above = directive.end <= region.start && region.start - directive.end <= lookBehind;
118
+ if (!inside && !above)
119
+ continue;
120
+ if (best === null || directive.start > best.start)
121
+ best = directive;
122
+ }
123
+ return best;
124
+ }
125
+ //# sourceMappingURL=directives.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directives.js","sourceRoot":"","sources":["../src/directives.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Explicit annotations, written as HTML comments.\n *\n * Inference covers the overwhelming majority of real documents, but inference\n * that cannot be overridden is a trap: the first time it guesses wrong about a\n * document somebody cannot restructure, the whole tool gets switched off. These\n * directives are the escape hatch, and they are HTML comments so they stay\n * invisible in GitHub, MkDocs, Docusaurus and every other renderer.\n *\n * ```md\n * <!-- @spec-node id=\"ADR-0007\" status=\"accepted\" aliases=\"adr-7, sharding\" -->\n * <!-- @spec-item id=\"shard-key\" state=\"narrowed\" -->\n * <!-- @spec-edge kind=\"delegates-to\" to=\"ADR-0011#scope\" -->\n * <!-- @spec-ignore -->\n * ```\n *\n * A directive always wins over anything inferred, and says so in the report, so\n * an override is visible rather than mysterious.\n */\n\nimport type { HtmlComment } from './markdown.js';\n\nexport type DirectiveName = 'spec-node' | 'spec-item' | 'spec-edge' | 'spec-ignore' | 'spec-history';\n\nconst KNOWN: ReadonlySet<string> = new Set<DirectiveName>([\n 'spec-node',\n 'spec-item',\n 'spec-edge',\n 'spec-ignore',\n 'spec-history',\n]);\n\nexport interface Attribute {\n readonly value: string;\n /** Absolute offset of the value text, quotes excluded. */\n readonly start: number;\n readonly end: number;\n}\n\nexport interface Directive {\n readonly name: DirectiveName;\n readonly attributes: ReadonlyMap<string, Attribute>;\n /** Offset of the enclosing comment. */\n readonly start: number;\n readonly end: number;\n readonly line: number;\n /** Attribute names that were written but are not part of this directive. */\n readonly unknownAttributes: readonly string[];\n}\n\nconst DIRECTIVE_HEAD = /^\\s*@([a-z][a-z0-9-]*)/;\nconst ATTRIBUTE = /([A-Za-z_][A-Za-z0-9_-]*)\\s*(?:=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s\"'<>]+)))?/g;\n\n/** Attributes each directive understands. Anything else is reported, not ignored. */\nconst SCHEMA: Readonly<Record<DirectiveName, readonly string[]>> = {\n 'spec-node': ['id', 'status', 'title', 'aliases', 'kind'],\n // Takes no attributes: the file either is a record or it is not.\n 'spec-history': [],\n 'spec-item': ['id', 'state', 'title', 'owner', 'note'],\n 'spec-edge': ['kind', 'to', 'from', 'reason'],\n 'spec-ignore': ['reason'],\n};\n\n/** Reads every spec-graph directive out of a document's HTML comments. */\nexport function parseDirectives(comments: readonly HtmlComment[]): Directive[] {\n const out: Directive[] = [];\n for (const comment of comments) {\n const head = DIRECTIVE_HEAD.exec(comment.inner);\n if (!head) continue;\n const name = head[1] as string;\n if (!KNOWN.has(name)) continue;\n\n const directiveName = name as DirectiveName;\n const bodyStart = (head.index ?? 0) + (head[0] as string).length;\n const body = comment.inner.slice(bodyStart);\n const attributes = new Map<string, Attribute>();\n const unknown: string[] = [];\n const allowed = SCHEMA[directiveName];\n\n ATTRIBUTE.lastIndex = 0;\n for (let m = ATTRIBUTE.exec(body); m !== null; m = ATTRIBUTE.exec(body)) {\n const key = (m[1] as string).toLowerCase();\n const quoted = m[3] ?? m[4];\n const bare = m[5];\n // A bare attribute is a flag: `<!-- @spec-ignore reason -->`.\n const value = quoted ?? bare ?? 'true';\n const raw = m[2];\n const valueOffset =\n raw === undefined\n ? (m.index ?? 0) + (m[0] as string).length\n : (m.index ?? 0) + (m[0] as string).indexOf(raw) + (quoted === undefined ? 0 : 1);\n const start = comment.innerStart + bodyStart + valueOffset;\n\n if (!allowed.includes(key)) unknown.push(key);\n attributes.set(key, { value, start, end: start + value.length });\n }\n\n out.push({\n name: directiveName,\n attributes,\n start: comment.start,\n end: comment.end,\n line: comment.line,\n unknownAttributes: unknown,\n });\n }\n return out;\n}\n\n/** Reads an attribute, trimmed. Returns `null` when absent or blank. */\nexport function attr(directive: Directive, name: string): Attribute | null {\n const found = directive.attributes.get(name);\n if (!found) return null;\n const trimmed = found.value.trim();\n if (trimmed.length === 0) return null;\n const offset = found.value.indexOf(trimmed);\n return { value: trimmed, start: found.start + offset, end: found.start + offset + trimmed.length };\n}\n\n/** Splits a comma- or whitespace-separated attribute into its parts. */\nexport function attrList(directive: Directive, name: string): string[] {\n const found = attr(directive, name);\n if (!found) return [];\n return found.value\n .split(/[,;\\s]+/)\n .map((part) => part.trim())\n .filter((part) => part.length > 0);\n}\n\n/**\n * Finds the directive that annotates a given region.\n *\n * A `@spec-item` directive may sit on the line above the item, on the same\n * line, or inside the item body. All three read naturally in the source, so all\n * three are accepted, with the closest preceding directive winning.\n */\nexport function directiveFor(\n directives: readonly Directive[],\n name: DirectiveName,\n region: { start: number; end: number },\n /** How far above the region a directive may sit and still apply. */\n lookBehind: number,\n): Directive | null {\n let best: Directive | null = null;\n for (const directive of directives) {\n if (directive.name !== name) continue;\n const inside = directive.start >= region.start && directive.end <= region.end;\n const above = directive.end <= region.start && region.start - directive.end <= lookBehind;\n if (!inside && !above) continue;\n if (best === null || directive.start > best.start) best = directive;\n }\n return best;\n}\n"]}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Document extraction: Markdown in, graph fragments out.
3
+ *
4
+ * This module is where domain-agnosticism is won or lost. It never requires a
5
+ * repository to adopt a syntax. It reads what teams already write - front
6
+ * matter, a `## Status` section, a checklist under `## Open Questions`, a link
7
+ * that says "deferred to ADR-0011" - and turns each into a typed relation with a
8
+ * source position.
9
+ *
10
+ * The one rule it holds to everywhere: **an inference that cannot be justified
11
+ * is not made.** An unrecognised status becomes `unknown`, not a guess. A link
12
+ * with no governing verb becomes a neutral `references`, not a dependency. Bare
13
+ * identifiers found in prose are opportunistic and are dropped silently when
14
+ * they do not resolve, because the alternative is reporting `SHA-256` as a
15
+ * broken reference to specification 256.
16
+ */
17
+ import { type DocumentIdentity } from './identity.js';
18
+ import { type ScannedDocument } from './markdown.js';
19
+ import type { DocumentNode, EdgeKind, EdgeOrigin, ItemNode, ParseProblem, SourceRef } from './types.js';
20
+ /**
21
+ * A relation that has been read but not yet resolved to a target node.
22
+ *
23
+ * Resolution needs the whole corpus, so extraction stops at the raw target text.
24
+ */
25
+ export interface ReferenceCandidate {
26
+ readonly kind: EdgeKind;
27
+ /** Id of the node the relation was written on. */
28
+ readonly from: string;
29
+ /** The target exactly as written: a path, an identifier, or a URL. */
30
+ readonly target: string;
31
+ readonly origin: EdgeOrigin;
32
+ readonly declaredAt: SourceRef;
33
+ readonly raw: string;
34
+ /**
35
+ * When true the relation runs `target -> from`.
36
+ *
37
+ * `superseded-by: ADR-0009` written in ADR-0003 declares that *ADR-0009*
38
+ * supersedes ADR-0003. The edge belongs to ADR-0009; the line a human must go
39
+ * and fix is in ADR-0003, which is why `declaredAt` is tracked separately.
40
+ */
41
+ readonly inverted: boolean;
42
+ /**
43
+ * Opportunistic references are dropped in silence when they do not resolve.
44
+ * Only deliberate ones - links, front matter, directives - are reported.
45
+ */
46
+ readonly opportunistic: boolean;
47
+ }
48
+ export interface ExtractedDocument {
49
+ readonly document: DocumentNode;
50
+ readonly items: readonly ItemNode[];
51
+ readonly references: readonly ReferenceCandidate[];
52
+ readonly problems: readonly ParseProblem[];
53
+ /** Heading slugs, for resolving `#anchor` references into this document. */
54
+ readonly anchors: ReadonlySet<string>;
55
+ readonly identity: DocumentIdentity;
56
+ readonly scanned: ScannedDocument;
57
+ /**
58
+ * Specifications declared inside this file, each a document in its own right.
59
+ *
60
+ * Empty for the ordinary one-file-per-decision layout, which is why nothing
61
+ * downstream had to change to support registers.
62
+ */
63
+ readonly subSpecifications: readonly ExtractedDocument[];
64
+ /**
65
+ * The file-level specification this one is a region of, if any.
66
+ *
67
+ * Recorded so the register's structure is a relation in the graph rather than
68
+ * an accident of which offsets fall inside which: "what decisions does this
69
+ * register hold" is a question worth being able to ask.
70
+ */
71
+ readonly containerId: string | null;
72
+ }
73
+ /** Headings under which a link is bookkeeping rather than a commitment. */
74
+ declare const WEAK_SECTIONS: ReadonlySet<string>;
75
+ /** Headings whose bullets are obligations even without a checkbox. */
76
+ declare const OBLIGATION_SECTIONS: ReadonlySet<string>;
77
+ export interface ExtractInput {
78
+ /** Repository-relative POSIX path. */
79
+ readonly path: string;
80
+ readonly text: string;
81
+ /**
82
+ * This file is a historical record: a log of what was decided, not a
83
+ * decision. Matched against the caller's history patterns, because deciding
84
+ * which files those are is a repository's business and not a guess this
85
+ * layer could justify making. See ADR-0011.
86
+ */
87
+ readonly record?: boolean | undefined;
88
+ }
89
+ /**
90
+ * Reads one file into graph fragments, one entry per specification in it.
91
+ *
92
+ * A file yields at least itself. It yields more when it is a register: a
93
+ * heading that carries an identifier *and* declares a status is a specification
94
+ * in its own right, with its own lifecycle, its own obligations and its own
95
+ * relations. See ADR-0009.
96
+ */
97
+ export declare function extractSpecifications(input: ExtractInput): ExtractedDocument[];
98
+ /** Reads a file's own specification. Returns `null` when it opts out. */
99
+ export declare function extractDocument(input: ExtractInput): ExtractedDocument | null;
100
+ /** The heading path enclosing an offset, outermost first. */
101
+ declare function sectionPathAt(scanned: ScannedDocument, offset: number): string[];
102
+ export interface Classification {
103
+ readonly kind: EdgeKind;
104
+ readonly inverted: boolean;
105
+ }
106
+ /**
107
+ * Decides what a reference means from the words around it.
108
+ *
109
+ * The nearest governing phrase within {@link VERB_WINDOW} characters wins, and
110
+ * the search never crosses a sentence boundary. With nothing to go on, a
111
+ * reference under a "See also" heading is bookkeeping and everything else is a
112
+ * neutral citation.
113
+ */
114
+ export declare function classifyReference(text: string, start: number, end: number, section: readonly string[]): Classification;
115
+ /** Exposed for tests and for callers that classify their own sections. */
116
+ export { OBLIGATION_SECTIONS, sectionPathAt, WEAK_SECTIONS };