@descent-vtt/spec-graph 0.5.0 → 0.7.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/dist/config.d.ts CHANGED
@@ -85,9 +85,9 @@ export interface DiscoveryIO {
85
85
  /**
86
86
  * Reads configuration from a directory.
87
87
  *
88
- * Never throws. A malformed config is reported as a problem and the run
89
- * continues with defaults: a broken config file should not stop a team seeing
90
- * the findings it was going to show them anyway.
88
+ * Never throws, and never decides. Every problem is collected and handed back
89
+ * whole, so the reader sees all of them at once rather than the first; what a
90
+ * problem costs is the caller's to say, and the CLI stops on one.
91
91
  */
92
92
  export declare function loadConfig(root: string, read?: (path: string) => string): LoadedConfig;
93
93
  /**
package/dist/config.js CHANGED
@@ -32,9 +32,9 @@ const EMPTY = { config: {}, source: null, problems: [] };
32
32
  /**
33
33
  * Reads configuration from a directory.
34
34
  *
35
- * Never throws. A malformed config is reported as a problem and the run
36
- * continues with defaults: a broken config file should not stop a team seeing
37
- * the findings it was going to show them anyway.
35
+ * Never throws, and never decides. Every problem is collected and handed back
36
+ * whole, so the reader sees all of them at once rather than the first; what a
37
+ * problem costs is the caller's to say, and the CLI stops on one.
38
38
  */
39
39
  export function loadConfig(root, read = defaultRead) {
40
40
  for (const name of CONFIG_FILES) {
@@ -59,7 +59,7 @@ export function loadConfig(root, read = defaultRead) {
59
59
  if (section === undefined)
60
60
  return EMPTY;
61
61
  if (!isRecord(section)) {
62
- return { config: {}, source: `package.json`, problems: [`"${CONFIG_PACKAGE_KEY}" must be an object`] };
62
+ return { config: {}, source: `package.json`, problems: [`package.json: "${CONFIG_PACKAGE_KEY}" must be an object`] };
63
63
  }
64
64
  return readFields(section, `package.json#${CONFIG_PACKAGE_KEY}`);
65
65
  }
@@ -1 +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 { existsSync, readFileSync } from 'node:fs';\n\nimport { dirnamePosix, toPosix } from './paths.js';\nimport { compileProjectRules, type ProjectRule } from './project-rules.js';\nimport { RULE_IDS } from './rules.js';\nimport { isProjectRule, type AnyRuleId, type RuleId, type 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 /** Fail when a baseline entry no longer occurs. See ADR-0012. */\n readonly ratchet?: boolean | undefined;\n readonly severities?: Partial<Record<AnyRuleId, Severity>> | undefined;\n readonly strict?: boolean | undefined;\n readonly maxRelated?: number | undefined;\n /**\n * Conventions this repository checks that spec-graph does not.\n *\n * A selector, a sentence and a severity. Compiled here rather than carried as\n * raw JSON, so a rule that cannot run is reported at load time next to every\n * other configuration problem - and not as a rule that quietly finds nothing.\n * See ADR-0016.\n */\n readonly rules?: readonly ProjectRule[] | 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\nexport interface DiscoveredConfig extends LoadedConfig {\n /**\n * The directory the configuration was found in.\n *\n * This is the root of the run. Every path in spec-graph is relative to it -\n * node identities, baseline keys, SARIF locations - so the configuration file\n * naming the repository is the same statement as the repository having a\n * root. See ADR-0018.\n */\n readonly root: string;\n}\n\n/** Injected so discovery can be tested without a directory tree. */\nexport interface DiscoveryIO {\n readonly read?: ((path: string) => string) | undefined;\n readonly exists?: ((path: string) => boolean) | undefined;\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/**\n * Finds the configuration by walking up from a directory.\n *\n * A monorepo is invoked from inside a package - `packages/auth`, or\n * `docs/architecture` - and reading configuration from the working directory\n * meant that the same repository checked differently depending on where\n * somebody stood in it. ADR-0010 worried that discovery would make a run\n * depend on where it started. It does the opposite: because the directory\n * holding the configuration becomes the root, a run from anywhere inside the\n * repository produces byte-identical output to a run from the top.\n *\n * The walk stops at the repository, which is a directory holding `.git`.\n * Above that is somebody else's checkout or a home directory, and a run that\n * silently picked up a configuration file nobody in the repository can see\n * would be worse than no discovery at all. See ADR-0018.\n */\nexport function discoverConfig(from: string, io: DiscoveryIO = {}): DiscoveredConfig {\n const read = io.read ?? defaultRead;\n const exists = io.exists ?? existsSync;\n const start = toPosix(from).replace(/\\/+$/, '');\n\n let directory = start;\n for (;;) {\n const loaded = loadConfig(directory, read);\n if (loaded.source !== null) return { ...loaded, root: directory };\n if (exists(`${directory}/.git`)) break;\n const parent = dirnamePosix(directory);\n // Two guards for one job, and each makes the other unreachable: a path with\n // no separator left in it gives back `''`, and `''` gives back itself. Only\n // one of them can ever fire, which is why neither has a test - removing\n // either leaves the loop terminating on the other.\n if (parent === '' || parent === directory) break;\n directory = parent;\n }\n return { ...EMPTY, root: start };\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['ratchet'] !== undefined) {\n if (typeof raw['ratchet'] !== 'boolean') problems.push(`${source}: \"ratchet\" must be true or false`);\n else config.ratchet = raw['ratchet'];\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<AnyRuleId, Severity>> = {};\n for (const [rule, level] of Object.entries(value)) {\n // A project rule is named here by its full id, `project:no-drafts`, the\n // same spelling `--rule` takes and a report prints. Whether one exists\n // is checked below, once both halves of the file have been read.\n if (!RULE_IDS.includes(rule as RuleId) && !isProjectRule(rule as AnyRuleId)) {\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 AnyRuleId] = level;\n }\n config.severities = severities;\n }\n }\n\n const compiled = raw['rules'] === undefined ? null : compileProjectRules(raw['rules'], source);\n if (compiled !== null) {\n problems.push(...compiled.problems);\n config.rules = compiled.rules;\n }\n\n // A severity naming a project rule that nothing defines is a line that will\n // never do anything, and reading like it does is the one thing a\n // configuration file must not be allowed to do.\n const defined = new Set<string>((compiled?.rules ?? []).map((rule) => rule.id));\n for (const rule of Object.keys(config.severities ?? {})) {\n if (!isProjectRule(rule as AnyRuleId) || defined.has(rule)) continue;\n problems.push(`${source}: \"severities.${rule}\" names no rule in \"rules\"`);\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 'ratchet',\n 'rules',\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"]}
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 { existsSync, readFileSync } from 'node:fs';\n\nimport { dirnamePosix, toPosix } from './paths.js';\nimport { compileProjectRules, type ProjectRule } from './project-rules.js';\nimport { RULE_IDS } from './rules.js';\nimport { isProjectRule, type AnyRuleId, type RuleId, type 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 /** Fail when a baseline entry no longer occurs. See ADR-0012. */\n readonly ratchet?: boolean | undefined;\n readonly severities?: Partial<Record<AnyRuleId, Severity>> | undefined;\n readonly strict?: boolean | undefined;\n readonly maxRelated?: number | undefined;\n /**\n * Conventions this repository checks that spec-graph does not.\n *\n * A selector, a sentence and a severity. Compiled here rather than carried as\n * raw JSON, so a rule that cannot run is reported at load time next to every\n * other configuration problem - and not as a rule that quietly finds nothing.\n * See ADR-0016.\n */\n readonly rules?: readonly ProjectRule[] | 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\nexport interface DiscoveredConfig extends LoadedConfig {\n /**\n * The directory the configuration was found in.\n *\n * This is the root of the run. Every path in spec-graph is relative to it -\n * node identities, baseline keys, SARIF locations - so the configuration file\n * naming the repository is the same statement as the repository having a\n * root. See ADR-0018.\n */\n readonly root: string;\n}\n\n/** Injected so discovery can be tested without a directory tree. */\nexport interface DiscoveryIO {\n readonly read?: ((path: string) => string) | undefined;\n readonly exists?: ((path: string) => boolean) | undefined;\n}\n\nconst EMPTY: LoadedConfig = { config: {}, source: null, problems: [] };\n\n/**\n * Reads configuration from a directory.\n *\n * Never throws, and never decides. Every problem is collected and handed back\n * whole, so the reader sees all of them at once rather than the first; what a\n * problem costs is the caller's to say, and the CLI stops on one.\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: [`package.json: \"${CONFIG_PACKAGE_KEY}\" must be an object`] };\n }\n return readFields(section, `package.json#${CONFIG_PACKAGE_KEY}`);\n}\n\n/**\n * Finds the configuration by walking up from a directory.\n *\n * A monorepo is invoked from inside a package - `packages/auth`, or\n * `docs/architecture` - and reading configuration from the working directory\n * meant that the same repository checked differently depending on where\n * somebody stood in it. ADR-0010 worried that discovery would make a run\n * depend on where it started. It does the opposite: because the directory\n * holding the configuration becomes the root, a run from anywhere inside the\n * repository produces byte-identical output to a run from the top.\n *\n * The walk stops at the repository, which is a directory holding `.git`.\n * Above that is somebody else's checkout or a home directory, and a run that\n * silently picked up a configuration file nobody in the repository can see\n * would be worse than no discovery at all. See ADR-0018.\n */\nexport function discoverConfig(from: string, io: DiscoveryIO = {}): DiscoveredConfig {\n const read = io.read ?? defaultRead;\n const exists = io.exists ?? existsSync;\n const start = toPosix(from).replace(/\\/+$/, '');\n\n let directory = start;\n for (;;) {\n const loaded = loadConfig(directory, read);\n if (loaded.source !== null) return { ...loaded, root: directory };\n if (exists(`${directory}/.git`)) break;\n const parent = dirnamePosix(directory);\n // Two guards for one job, and each makes the other unreachable: a path with\n // no separator left in it gives back `''`, and `''` gives back itself. Only\n // one of them can ever fire, which is why neither has a test - removing\n // either leaves the loop terminating on the other.\n if (parent === '' || parent === directory) break;\n directory = parent;\n }\n return { ...EMPTY, root: start };\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['ratchet'] !== undefined) {\n if (typeof raw['ratchet'] !== 'boolean') problems.push(`${source}: \"ratchet\" must be true or false`);\n else config.ratchet = raw['ratchet'];\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<AnyRuleId, Severity>> = {};\n for (const [rule, level] of Object.entries(value)) {\n // A project rule is named here by its full id, `project:no-drafts`, the\n // same spelling `--rule` takes and a report prints. Whether one exists\n // is checked below, once both halves of the file have been read.\n if (!RULE_IDS.includes(rule as RuleId) && !isProjectRule(rule as AnyRuleId)) {\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 AnyRuleId] = level;\n }\n config.severities = severities;\n }\n }\n\n const compiled = raw['rules'] === undefined ? null : compileProjectRules(raw['rules'], source);\n if (compiled !== null) {\n problems.push(...compiled.problems);\n config.rules = compiled.rules;\n }\n\n // A severity naming a project rule that nothing defines is a line that will\n // never do anything, and reading like it does is the one thing a\n // configuration file must not be allowed to do.\n const defined = new Set<string>((compiled?.rules ?? []).map((rule) => rule.id));\n for (const rule of Object.keys(config.severities ?? {})) {\n if (!isProjectRule(rule as AnyRuleId) || defined.has(rule)) continue;\n problems.push(`${source}: \"severities.${rule}\" names no rule in \"rules\"`);\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 'ratchet',\n 'rules',\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"]}
package/dist/diff.d.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * What changed between two states of the graph, read from two JSON exports.
3
+ *
4
+ * Two files in, one report out, and no git underneath: CI already has both
5
+ * checkouts, and the pipeline stays a pure function of text. See ADR-0020.
6
+ *
7
+ * The rule the whole module follows is that a change is reported only against a
8
+ * name that means the same thing in both exports. A document's id does. An
9
+ * item's numbered id does not - it is an ordinal within a section, so one
10
+ * inserted question renumbers every question after it - and keying on it
11
+ * reports a question as reopened when nobody touched it. So an obligation is
12
+ * paired only by a declared id, or by its document, section and title when
13
+ * nothing else shares them; anything unpaired is said to have appeared or
14
+ * disappeared, and never to have changed.
15
+ */
16
+ /** Refused input: not JSON, not an export, or a version this cannot read. */
17
+ export declare class DiffInputError extends Error {
18
+ constructor(message: string);
19
+ }
20
+ export interface Generator {
21
+ readonly name: string;
22
+ readonly version: string;
23
+ }
24
+ export interface ExportedDocument {
25
+ readonly id: string;
26
+ readonly title: string;
27
+ readonly path: string;
28
+ readonly phase: string;
29
+ readonly status: string | null;
30
+ }
31
+ export interface ExportedItem {
32
+ readonly id: string;
33
+ readonly title: string;
34
+ readonly document: string;
35
+ readonly section: readonly string[];
36
+ readonly openness: string;
37
+ /** `null` when the export predates the flag, so its ids cannot be trusted. */
38
+ readonly declared: boolean | null;
39
+ }
40
+ export interface ExportedRelation {
41
+ readonly kind: string;
42
+ readonly from: string;
43
+ readonly to: string;
44
+ }
45
+ /** One side of a diff: a `--graph-format json` export, read and checked. */
46
+ export interface GraphExport {
47
+ readonly generator: Generator | null;
48
+ readonly documents: readonly ExportedDocument[];
49
+ readonly items: readonly ExportedItem[];
50
+ readonly edges: readonly ExportedRelation[];
51
+ }
52
+ export interface DocumentChange {
53
+ readonly id: string;
54
+ readonly path?: readonly [string, string] | undefined;
55
+ readonly title?: readonly [string, string] | undefined;
56
+ readonly phase?: readonly [string, string] | undefined;
57
+ readonly status?: readonly [string | null, string | null] | undefined;
58
+ }
59
+ export interface ObligationRef {
60
+ readonly document: string;
61
+ readonly section: readonly string[];
62
+ /**
63
+ * The section path as a reader wants it: without the document's own title
64
+ * heading, which every item under it shares and the id already names. Items
65
+ * are matched on the same headings, kept as a list so that no heading holding
66
+ * " > " can pass for two.
67
+ */
68
+ readonly place: string;
69
+ readonly title: string;
70
+ }
71
+ /** An item with no pair: its state on the one side it exists on, and no more. */
72
+ export interface UnpairedObligation extends ObligationRef {
73
+ readonly openness: string;
74
+ }
75
+ export interface Transition extends ObligationRef {
76
+ readonly openness: readonly [string, string];
77
+ }
78
+ export interface GraphDiff {
79
+ readonly generators: {
80
+ readonly before: Generator | null;
81
+ readonly after: Generator | null;
82
+ };
83
+ readonly documents: {
84
+ readonly added: readonly ExportedDocument[];
85
+ readonly removed: readonly ExportedDocument[];
86
+ readonly changed: readonly DocumentChange[];
87
+ };
88
+ readonly relations: {
89
+ readonly added: readonly ExportedRelation[];
90
+ readonly removed: readonly ExportedRelation[];
91
+ };
92
+ readonly obligations: {
93
+ readonly transitions: readonly Transition[];
94
+ readonly appeared: readonly UnpairedObligation[];
95
+ readonly disappeared: readonly UnpairedObligation[];
96
+ };
97
+ }
98
+ /**
99
+ * Reads one export.
100
+ *
101
+ * Checked as far as the comparison reads it and no further: a field this does
102
+ * not use is not a reason to refuse a file, and a later export that adds fields
103
+ * is still version 1.
104
+ */
105
+ export declare function parseGraphExport(raw: string, source: string): GraphExport;
106
+ /** Compares two exports. Pure, and deterministic in everything it returns. */
107
+ export declare function diffExports(before: GraphExport, after: GraphExport): GraphDiff;
108
+ /**
109
+ * The warning a diff carries when its two exports may disagree for a reason
110
+ * that is not the repository. `null` when both name the same generator.
111
+ */
112
+ export declare function generatorWarning(diff: GraphDiff): string | null;
113
+ /** One sentence for the whole diff, which is what a pull-request reader sees first. */
114
+ export declare function diffVerdict(diff: GraphDiff): string;
115
+ /** The report for a terminal. */
116
+ export declare function formatDiffText(diff: GraphDiff): string;
117
+ /** The report for a bot: the diff itself, with the verdict and warning beside it. */
118
+ export declare function formatDiffJson(diff: GraphDiff): string;
119
+ /** The report for a job summary or a pull-request comment. */
120
+ export declare function formatDiffMarkdown(diff: GraphDiff): string;
package/dist/diff.js ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * What changed between two states of the graph, read from two JSON exports.
3
+ *
4
+ * Two files in, one report out, and no git underneath: CI already has both
5
+ * checkouts, and the pipeline stays a pure function of text. See ADR-0020.
6
+ *
7
+ * The rule the whole module follows is that a change is reported only against a
8
+ * name that means the same thing in both exports. A document's id does. An
9
+ * item's numbered id does not - it is an ordinal within a section, so one
10
+ * inserted question renumbers every question after it - and keying on it
11
+ * reports a question as reopened when nobody touched it. So an obligation is
12
+ * paired only by a declared id, or by its document, section and title when
13
+ * nothing else shares them; anything unpaired is said to have appeared or
14
+ * disappeared, and never to have changed.
15
+ */
16
+ /** Refused input: not JSON, not an export, or a version this cannot read. */
17
+ export class DiffInputError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'DiffInputError';
21
+ }
22
+ }
23
+ /* -------------------------------------------------------------------------- */
24
+ /* Reading an export */
25
+ /* -------------------------------------------------------------------------- */
26
+ function isRecord(value) {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+ function text(value) {
30
+ return typeof value === 'string' ? value : null;
31
+ }
32
+ /**
33
+ * Reads one export.
34
+ *
35
+ * Checked as far as the comparison reads it and no further: a field this does
36
+ * not use is not a reason to refuse a file, and a later export that adds fields
37
+ * is still version 1.
38
+ */
39
+ export function parseGraphExport(raw, source) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ throw new DiffInputError(`${source} is not JSON; make it with \`spec-graph graph --graph-format json\``);
46
+ }
47
+ if (!isRecord(parsed) || !Array.isArray(parsed['nodes']) || !Array.isArray(parsed['edges'])) {
48
+ throw new DiffInputError(`${source} is not a graph export; make it with \`spec-graph graph --graph-format json\``);
49
+ }
50
+ if (parsed['version'] !== 1) {
51
+ throw new DiffInputError(`${source} is export version ${JSON.stringify(parsed['version'])}, and this reads version 1`);
52
+ }
53
+ const generator = isRecord(parsed['generator']) ? parsed['generator'] : null;
54
+ const name = generator ? text(generator['name']) : null;
55
+ const version = generator ? text(generator['version']) : null;
56
+ const documents = [];
57
+ const items = [];
58
+ parsed['nodes'].forEach((node, index) => {
59
+ const id = isRecord(node) ? text(node['id']) : null;
60
+ if (!isRecord(node) || id === null)
61
+ throw new DiffInputError(`${source}: node ${index} has no id`);
62
+ if (node['kind'] === 'document') {
63
+ documents.push({
64
+ id,
65
+ title: text(node['title']) ?? '',
66
+ path: text(node['path']) ?? '',
67
+ phase: text(node['phase']) ?? 'unknown',
68
+ status: text(node['status']),
69
+ });
70
+ }
71
+ else if (node['kind'] === 'item') {
72
+ const section = Array.isArray(node['section']) ? node['section'].filter((part) => typeof part === 'string') : [];
73
+ items.push({
74
+ id,
75
+ title: text(node['title']) ?? '',
76
+ document: text(node['document']) ?? '',
77
+ section,
78
+ openness: text(node['openness']) ?? 'unknown',
79
+ declared: typeof node['declared'] === 'boolean' ? node['declared'] : null,
80
+ });
81
+ }
82
+ else {
83
+ throw new DiffInputError(`${source}: node ${id} is neither a document nor an item`);
84
+ }
85
+ });
86
+ const edges = parsed['edges'].map((edge, index) => {
87
+ const kind = isRecord(edge) ? text(edge['kind']) : null;
88
+ const from = isRecord(edge) ? text(edge['from']) : null;
89
+ const to = isRecord(edge) ? text(edge['to']) : null;
90
+ if (kind === null || from === null || to === null)
91
+ throw new DiffInputError(`${source}: edge ${index} is missing its kind or ends`);
92
+ return { kind, from, to };
93
+ });
94
+ return { generator: name !== null && version !== null ? { name, version } : null, documents, items, edges };
95
+ }
96
+ /* -------------------------------------------------------------------------- */
97
+ /* Comparing */
98
+ /* -------------------------------------------------------------------------- */
99
+ // Code-unit order, not locale order: the same input sorts the same way on every
100
+ // machine, which is what keeps a report byte-identical.
101
+ function byText(a, b) {
102
+ return a < b ? -1 : a > b ? 1 : 0;
103
+ }
104
+ /** Compares two exports. Pure, and deterministic in everything it returns. */
105
+ export function diffExports(before, after) {
106
+ return {
107
+ generators: { before: before.generator, after: after.generator },
108
+ documents: compareDocuments(before, after),
109
+ relations: compareRelations(before, after),
110
+ obligations: compareObligations(before, after),
111
+ };
112
+ }
113
+ function compareDocuments(before, after) {
114
+ const was = new Map(before.documents.map((document) => [document.id, document]));
115
+ const now = new Map(after.documents.map((document) => [document.id, document]));
116
+ const changed = [];
117
+ for (const [id, next] of now) {
118
+ const previous = was.get(id);
119
+ if (previous === undefined)
120
+ continue;
121
+ // The id is the decision's name, so the same id at a new path is the same
122
+ // decision moved, never one removed and another added (ADR-0009).
123
+ const change = {
124
+ id,
125
+ ...(previous.path !== next.path && { path: [previous.path, next.path] }),
126
+ ...(previous.title !== next.title && { title: [previous.title, next.title] }),
127
+ ...(previous.phase !== next.phase && { phase: [previous.phase, next.phase] }),
128
+ ...(previous.status !== next.status && { status: [previous.status, next.status] }),
129
+ };
130
+ if (Object.keys(change).length > 1)
131
+ changed.push(change);
132
+ }
133
+ const ids = (documents) => [...documents].sort((a, b) => byText(a.id, b.id));
134
+ return {
135
+ added: ids([...now.values()].filter((document) => !was.has(document.id))),
136
+ removed: ids([...was.values()].filter((document) => !now.has(document.id))),
137
+ changed: changed.sort((a, b) => byText(a.id, b.id)),
138
+ };
139
+ }
140
+ /**
141
+ * The relations between documents, projected the way `--documents-only`
142
+ * projects a graph: an item's edges lifted onto its document, and a relation
143
+ * that ends where it began dropped - which is also what becomes of a document
144
+ * containing its own items, so containment survives only between documents.
145
+ * Where a relation was declared is not part of it - moving a supersession from
146
+ * a sentence into front matter changes nothing a reader relies on.
147
+ */
148
+ function relationsOf(graph) {
149
+ // A document, or a target nothing in the export defines, stands for itself.
150
+ const owners = new Map(graph.items.map((item) => [item.id, item.document]));
151
+ const out = new Map();
152
+ for (const edge of graph.edges) {
153
+ const from = owners.get(edge.from) ?? edge.from;
154
+ const to = owners.get(edge.to) ?? edge.to;
155
+ if (from === to)
156
+ continue;
157
+ // Tab-joined: kinds and ids cannot contain one.
158
+ out.set(`${edge.kind}\t${from}\t${to}`, { kind: edge.kind, from, to });
159
+ }
160
+ return out;
161
+ }
162
+ function compareRelations(before, after) {
163
+ const was = relationsOf(before);
164
+ const now = relationsOf(after);
165
+ const sorted = (entries) => entries.sort(([a], [b]) => byText(a, b)).map(([, relation]) => relation);
166
+ return {
167
+ added: sorted([...now].filter(([key]) => !was.has(key))),
168
+ removed: sorted([...was].filter(([key]) => !now.has(key))),
169
+ };
170
+ }
171
+ /**
172
+ * Obligations by where they sit and then by title, heading by heading, so a
173
+ * section's own questions come before those of the sections under it.
174
+ */
175
+ function byPlace(a, b) {
176
+ const left = [a.document, ...a.section];
177
+ const right = [b.document, ...b.section];
178
+ for (let at = 0; at < left.length && at < right.length; at++) {
179
+ const order = byText(left[at], right[at]);
180
+ if (order !== 0)
181
+ return order;
182
+ }
183
+ return left.length - right.length || byText(a.title, b.title);
184
+ }
185
+ /**
186
+ * The headings an item sits under, below its document's own. A section path
187
+ * starts at the document's heading, which is its title, so without this a
188
+ * retitled document would lose track of every question in it.
189
+ */
190
+ function headingsIn(graph) {
191
+ const titles = new Map(graph.documents.map((document) => [document.id, document.title]));
192
+ return (item) => (item.section[0] === titles.get(item.document) ? item.section.slice(1) : item.section);
193
+ }
194
+ function compareObligations(before, after) {
195
+ const headingsBefore = headingsIn(before);
196
+ const headingsAfter = headingsIn(after);
197
+ const pairs = [];
198
+ const paired = new Set();
199
+ // A declared id names the same item in both states however it was reworded or
200
+ // moved - provided it names one item on each side. An export without the flag
201
+ // cannot say which ids were declared, so none of its ids are trusted.
202
+ const declaredIn = (items) => {
203
+ const out = new Map();
204
+ for (const item of items) {
205
+ if (item.declared !== true)
206
+ continue;
207
+ out.set(item.id, out.has(item.id) ? null : item);
208
+ }
209
+ return out;
210
+ };
211
+ const declaredBefore = declaredIn(before.items);
212
+ const declaredAfter = declaredIn(after.items);
213
+ for (const [id, item] of declaredBefore) {
214
+ const match = declaredAfter.get(id);
215
+ if (item === null || match === null || match === undefined)
216
+ continue;
217
+ pairs.push([item, match]);
218
+ paired.add(item);
219
+ paired.add(match);
220
+ }
221
+ // Otherwise the same document, headings and title, and only when nothing else
222
+ // on either side shares them: two identical titles in one section cannot be
223
+ // told apart, so neither is paired. Joined as JSON so that no two different
224
+ // triples can produce the same key.
225
+ const group = (items, headings) => {
226
+ const out = new Map();
227
+ for (const item of items) {
228
+ if (paired.has(item))
229
+ continue;
230
+ const key = JSON.stringify([item.document, headings(item), item.title]);
231
+ out.set(key, [...(out.get(key) ?? []), item]);
232
+ }
233
+ return out;
234
+ };
235
+ const was = group(before.items, headingsBefore);
236
+ const now = group(after.items, headingsAfter);
237
+ for (const [key, previous] of was) {
238
+ const next = now.get(key);
239
+ if (previous.length !== 1 || next?.length !== 1)
240
+ continue;
241
+ const [a, b] = [previous[0], next[0]];
242
+ pairs.push([a, b]);
243
+ paired.add(a);
244
+ paired.add(b);
245
+ }
246
+ const refIn = (headings) => (item) => ({
247
+ document: item.document,
248
+ section: item.section,
249
+ place: headings(item).join(' > '),
250
+ title: item.title,
251
+ });
252
+ const wasRef = refIn(headingsBefore);
253
+ const nowRef = refIn(headingsAfter);
254
+ return {
255
+ transitions: pairs
256
+ .filter(([a, b]) => a.openness !== b.openness)
257
+ .map(([a, b]) => ({ ...nowRef(b), openness: [a.openness, b.openness] }))
258
+ .sort(byPlace),
259
+ appeared: after.items.filter((item) => !paired.has(item)).map((item) => ({ ...nowRef(item), openness: item.openness })).sort(byPlace),
260
+ disappeared: before.items.filter((item) => !paired.has(item)).map((item) => ({ ...wasRef(item), openness: item.openness })).sort(byPlace),
261
+ };
262
+ }
263
+ /* -------------------------------------------------------------------------- */
264
+ /* Reporting */
265
+ /* -------------------------------------------------------------------------- */
266
+ function generatorName(generator) {
267
+ return generator === null ? 'an unknown version' : `${generator.name} ${generator.version}`;
268
+ }
269
+ /**
270
+ * The warning a diff carries when its two exports may disagree for a reason
271
+ * that is not the repository. `null` when both name the same generator.
272
+ */
273
+ export function generatorWarning(diff) {
274
+ const { before, after } = diff.generators;
275
+ if (before !== null && after !== null && before.name === after.name && before.version === after.version)
276
+ return null;
277
+ return `the exports were made by ${generatorName(before)} and ${generatorName(after)}; a change in extraction between versions reads as a change in the repository`;
278
+ }
279
+ function changeCount(diff) {
280
+ return {
281
+ documents: diff.documents.added.length + diff.documents.removed.length + diff.documents.changed.length,
282
+ relations: diff.relations.added.length + diff.relations.removed.length,
283
+ obligations: diff.obligations.transitions.length + diff.obligations.appeared.length + diff.obligations.disappeared.length,
284
+ };
285
+ }
286
+ function plural(n, word) {
287
+ return `${n} ${n === 1 ? word : `${word}s`}`;
288
+ }
289
+ /** One sentence for the whole diff, which is what a pull-request reader sees first. */
290
+ export function diffVerdict(diff) {
291
+ const count = changeCount(diff);
292
+ const parts = [
293
+ count.documents > 0 ? plural(count.documents, 'document') : null,
294
+ count.relations > 0 ? plural(count.relations, 'relation') : null,
295
+ count.obligations > 0 ? plural(count.obligations, 'obligation') : null,
296
+ ].filter((part) => part !== null);
297
+ if (parts.length === 0)
298
+ return 'No relational change.';
299
+ const listed = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
300
+ return `${listed} changed.`;
301
+ }
302
+ function where(ref) {
303
+ return ref.place === '' ? ref.document : `${ref.document} > ${ref.place}`;
304
+ }
305
+ function transitionWord([from, to]) {
306
+ return to === 'closed' ? 'resolved' : from === 'closed' ? 'reopened' : to === 'partial' ? 'narrowed' : 'widened';
307
+ }
308
+ function describeChange(change) {
309
+ const parts = [];
310
+ if (change.path)
311
+ parts.push(`moved from ${change.path[0]} to ${change.path[1]}`);
312
+ if (change.phase)
313
+ parts.push(`${change.phase[0]} -> ${change.phase[1]}`);
314
+ if (change.status)
315
+ parts.push(`status ${change.status[0] ?? 'none'} -> ${change.status[1] ?? 'none'}`);
316
+ if (change.title)
317
+ parts.push(`retitled "${change.title[1]}"`);
318
+ return parts.join('; ');
319
+ }
320
+ /** The report for a terminal. */
321
+ export function formatDiffText(diff) {
322
+ const lines = [];
323
+ const warning = generatorWarning(diff);
324
+ if (warning !== null)
325
+ lines.push(`warning: ${warning}`, '');
326
+ lines.push(diffVerdict(diff));
327
+ const { documents, relations, obligations } = diff;
328
+ if (documents.added.length + documents.removed.length + documents.changed.length > 0) {
329
+ lines.push('', 'Documents');
330
+ for (const document of documents.added)
331
+ lines.push(` + ${document.id} ${document.title} (${document.phase})`);
332
+ for (const document of documents.removed)
333
+ lines.push(` - ${document.id} ${document.title}`);
334
+ for (const change of documents.changed)
335
+ lines.push(` ~ ${change.id} ${describeChange(change)}`);
336
+ }
337
+ if (relations.added.length + relations.removed.length > 0) {
338
+ lines.push('', 'Relations');
339
+ for (const relation of relations.added)
340
+ lines.push(` + ${relation.from} -${relation.kind}-> ${relation.to}`);
341
+ for (const relation of relations.removed)
342
+ lines.push(` - ${relation.from} -${relation.kind}-> ${relation.to}`);
343
+ }
344
+ if (obligations.transitions.length + obligations.appeared.length + obligations.disappeared.length > 0) {
345
+ lines.push('', 'Obligations');
346
+ // Padded to the longest word, so the places line up.
347
+ const word = (text) => text.padEnd('disappeared'.length);
348
+ for (const transition of obligations.transitions) {
349
+ lines.push(` ${word(transitionWord(transition.openness))} ${where(transition)}: "${transition.title}" (${transition.openness[0]} -> ${transition.openness[1]})`);
350
+ }
351
+ for (const item of obligations.appeared)
352
+ lines.push(` ${word('appeared')} ${where(item)}: "${item.title}" (${item.openness})`);
353
+ for (const item of obligations.disappeared)
354
+ lines.push(` disappeared ${where(item)}: "${item.title}" (was ${item.openness})`);
355
+ }
356
+ return `${lines.join('\n')}\n`;
357
+ }
358
+ /** The report for a bot: the diff itself, with the verdict and warning beside it. */
359
+ export function formatDiffJson(diff) {
360
+ return `${JSON.stringify({ version: 1, verdict: diffVerdict(diff), warning: generatorWarning(diff), ...diff }, null, 2)}\n`;
361
+ }
362
+ // A table cell: a pipe would end the cell, and a newline would end the row.
363
+ function cell(value) {
364
+ return value.replace(/[|]/g, '&#124;').replace(/\s+/g, ' ');
365
+ }
366
+ /** The report for a job summary or a pull-request comment. */
367
+ export function formatDiffMarkdown(diff) {
368
+ const lines = ['### spec-graph diff', '', diffVerdict(diff), ''];
369
+ const warning = generatorWarning(diff);
370
+ if (warning !== null)
371
+ lines.push(`> **Warning:** ${cell(warning)}.`, '');
372
+ const { documents, relations, obligations } = diff;
373
+ if (documents.added.length + documents.removed.length + documents.changed.length > 0) {
374
+ lines.push('| | Document | Change |', '| --- | --- | --- |');
375
+ for (const document of documents.added)
376
+ lines.push(`| added | \`${cell(document.id)}\` | ${cell(document.title)} - ${cell(document.phase)} |`);
377
+ for (const document of documents.removed)
378
+ lines.push(`| removed | \`${cell(document.id)}\` | ${cell(document.title)} |`);
379
+ for (const change of documents.changed)
380
+ lines.push(`| changed | \`${cell(change.id)}\` | ${cell(describeChange(change))} |`);
381
+ lines.push('');
382
+ }
383
+ if (relations.added.length + relations.removed.length > 0) {
384
+ lines.push('| | From | Relation | To |', '| --- | --- | --- | --- |');
385
+ for (const relation of relations.added)
386
+ lines.push(`| added | \`${cell(relation.from)}\` | ${cell(relation.kind)} | \`${cell(relation.to)}\` |`);
387
+ for (const relation of relations.removed)
388
+ lines.push(`| removed | \`${cell(relation.from)}\` | ${cell(relation.kind)} | \`${cell(relation.to)}\` |`);
389
+ lines.push('');
390
+ }
391
+ if (obligations.transitions.length + obligations.appeared.length + obligations.disappeared.length > 0) {
392
+ lines.push('| | Where | Obligation |', '| --- | --- | --- |');
393
+ for (const transition of obligations.transitions) {
394
+ lines.push(`| ${transitionWord(transition.openness)} | ${cell(where(transition))} | ${cell(transition.title)} (${transition.openness[0]} -> ${transition.openness[1]}) |`);
395
+ }
396
+ for (const item of obligations.appeared)
397
+ lines.push(`| appeared | ${cell(where(item))} | ${cell(item.title)} (${item.openness}) |`);
398
+ for (const item of obligations.disappeared)
399
+ lines.push(`| disappeared | ${cell(where(item))} | ${cell(item.title)} (was ${item.openness}) |`);
400
+ lines.push('');
401
+ }
402
+ return `${lines.join('\n').trimEnd()}\n`;
403
+ }
404
+ //# sourceMappingURL=diff.js.map