@descent-vtt/spec-graph 0.17.0 → 0.19.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.
@@ -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 but `rules` and `maxRelated` mirrors a flag, and a flag always\n * wins. Configuration is where a team writes down what is true of the\n * repository; a flag is where somebody overrides it for one run.\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\n\nimport { isFamilyName } from './identity.js';\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 *\n * A configuration file that is there and cannot be read is a problem like one\n * that does not parse. Passed over as absent, a directory of that name or a\n * file the run may not open left the run on defaults, or on the configuration\n * of a directory above, with nothing said.\n */\nexport function loadConfig(root: string, read: (path: string) => string = defaultRead): LoadedConfig {\n for (const name of CONFIG_FILES) {\n let raw: string;\n try {\n raw = read(`${root}/${name}`);\n } catch (error) {\n // Nothing by that name: ENOENT, or ENOTDIR where a directory above is a\n // file. A reader handed in by a caller says the same with an error that\n // has no code.\n const code = (error as { code?: unknown }).code;\n if (code === undefined || code === 'ENOENT' || code === 'ENOTDIR') continue;\n return { config: {}, source: name, problems: [`${name} cannot be read: ${(error as Error).message}`] };\n }\n return parseConfig(raw, name);\n }\n\n // The first two returns are the third one's answer given early: no file is\n // `JSON.parse(null)`, which is `null`, and a parse that failed leaves\n // `parsed` undefined - neither is a record. Their mutants are equivalent.\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 // A path with no separator left in it gives back `''`, and stopping there is\n // what keeps a relative start from going on to read `/.spec-graph.json`,\n // which is what a file name joined to `''` spells. The second guard is true\n // only when the directory is `''` already, and then so is the first: its\n // mutant is equivalent. It stays so the loop still ends if the parent of a\n // path is ever spelled as Node's `dirname` spells it, `.` for `.`.\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 // A name no family has matches no citation. Alone in \"families\" it leaves\n // every citation as prose, and in \"ignoreFamilies\" it is a line that reads\n // as applied and does nothing.\n const families = (key: 'families' | 'ignoreFamilies'): readonly string[] | undefined => {\n const names = strings(key);\n for (const name of names ?? []) {\n if (!isFamilyName(name)) problems.push(`${source}: \"${key}\" must be family names, such as \"ADR\", and \"${name}\" is not one`);\n }\n return names;\n };\n\n config.families = families('families');\n config.ignoreFamilies = families('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 // `Number.isInteger` is false for anything but a number, so the `typeof`\n // only narrows the type for `value < 0`, and its mutant is equivalent.\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. (A fallback seeded with\n // something that is not a rule defines no id, so that mutant is equivalent.)\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 // package.json is the one file still read this way, and its reader\n // passes over a text that does not parse as it does over no file: the\n // mutant that answers nothing here is equivalent.\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 but `rules` and `maxRelated` mirrors a flag, and a flag always\n * wins. Configuration is where a team writes down what is true of the\n * repository; a flag is where somebody overrides it for one run.\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\n\nimport { isFamilyName } from './identity.js';\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 *\n * A configuration file that is there and cannot be read is a problem like one\n * that does not parse. Passed over as absent, a directory of that name or a\n * file the run may not open left the run on defaults, or on the configuration\n * of a directory above, with nothing said.\n */\nexport function loadConfig(root: string, read: (path: string) => string = defaultRead): LoadedConfig {\n for (const name of CONFIG_FILES) {\n let raw: string;\n try {\n raw = read(`${root}/${name}`);\n } catch (error) {\n // Nothing by that name: ENOENT, or ENOTDIR where a directory above is a\n // file. A reader handed in by a caller says the same with an error that\n // has no code.\n const code = (error as { code?: unknown }).code;\n if (code === undefined || code === 'ENOENT' || code === 'ENOTDIR') continue;\n return { config: {}, source: name, problems: [`${name} cannot be read: ${(error as Error).message}`] };\n }\n return parseConfig(raw, name);\n }\n\n // The first two returns are the third one's answer given early: no file is\n // `JSON.parse(null)`, which is `null`, and a parse that failed leaves\n // `parsed` undefined - neither is a record. Their mutants are equivalent.\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 // A path with no separator left in it gives back `''`, and stopping there is\n // what keeps a relative start from going on to read `/.spec-graph.json`,\n // which is what a file name joined to `''` spells. The second guard is true\n // only when the directory is `''` already, and then so is the first: its\n // mutant is equivalent. It stays so the loop still ends if the parent of a\n // path is ever spelled as Node's `dirname` spells it, `.` for `.`.\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 // A name no family has matches no citation. Alone in \"families\" it leaves\n // every citation as prose, and in \"ignoreFamilies\" it is a line that reads\n // as applied and does nothing.\n const families = (key: 'families' | 'ignoreFamilies'): readonly string[] | undefined => {\n const names = strings(key);\n for (const name of names ?? []) {\n if (!isFamilyName(name)) problems.push(`${source}: \"${key}\" must be family names, such as \"ADR\", and \"${name}\" is not one`);\n }\n return names;\n };\n\n config.families = families('families');\n config.ignoreFamilies = families('ignoreFamilies');\n // A family on both lists is passed over, since the list that ignores is\n // asked first, and its name on the other reads as applied and does\n // nothing: \"families\": [\"RFC\"] beside \"ignoreFamilies\": [\"RFC\"] passed\n // over every citation in prose, and the check that fails without the two\n // reported a consistent graph. The command line refuses the same two\n // options given together, and a file is held to what a line is. A name no\n // family has is reported above, and once is enough. A name planted in\n // either fallback is none, so it is never on both lists: equivalent.\n const ignored = new Set((config.ignoreFamilies ?? []).map((name) => name.trim().toUpperCase()));\n for (const name of config.families ?? []) {\n if (isFamilyName(name) && ignored.has(name.trim().toUpperCase())) {\n problems.push(`${source}: \"${name}\" is in both \"families\" and \"ignoreFamilies\", and a family's citations are read or they are passed over: take it off one of the two`);\n }\n }\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 // `Number.isInteger` is false for anything but a number, so the `typeof`\n // only narrows the type for `value < 0`, and its mutant is equivalent.\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. (A fallback seeded with\n // something that is not a rule defines no id, so that mutant is equivalent.)\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 // package.json is the one file still read this way, and its reader\n // passes over a text that does not parse as it does over no file: the\n // mutant that answers nothing here is equivalent.\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/extract.js CHANGED
@@ -330,8 +330,48 @@ const VERB_RULES = [
330
330
  * where the reference begins, and `nobody[ADR-7]` has nothing after it. A
331
331
  * hyphen is not one. It joins the word to the next, and "the no-op" or "the
332
332
  * never-ending migration" names a thing and denies nothing (ADR-0006).
333
+ *
334
+ * The word begins where it ends, after anything but a letter, a digit or a
335
+ * hyphen, so `Blocked by:none` and `none` between two em dashes are read as
336
+ * `by none` is. `no-one` is `no one` written with a hyphen, and on the list
337
+ * for that: the hyphen that makes `no-op` a name makes `no-one` a word
338
+ * (ADR-0006, 2026-10-18).
339
+ */
340
+ const NEGATION = /(?:^|[^\p{L}\p{N}-])(?:no|not|no-one|nobody|none|never|neither|nor|nothing|without)(?![\p{L}\p{N}-])/u;
341
+ /**
342
+ * A negation that stands before a governing phrase, which cancels it as one
343
+ * after it does: "Not blocked by [ADR-7]", "it has not been superseded by
344
+ * [ADR-7]" and "this doesn't depend on [ADR-7]" each deny the relation the
345
+ * phrase would state (ADR-0006, 2026-10-18).
346
+ *
347
+ * It is the last word before the phrase, or before a few words that carry a
348
+ * verb's tense, or leave the relation in doubt, and no claim of their own:
349
+ * `be`, `been`, `is`, `yet`, `longer`, `necessarily` and the like, so "no
350
+ * longer blocked by", "not yet deferred to" and "not necessarily blocked
351
+ * by" are read, and "not only blocked by" and "nothing but blocked by",
352
+ * which state the relation, are not. Only a space stands between, so a
353
+ * mark keeps the word to itself: the option `"never" blocked by` states the
354
+ * relation, as "Not in this release: deferred to" does. A comma does too,
355
+ * and ends the clause the word belongs to: "If not, blocked by" and "both
356
+ * or neither, because" state the relation, where with the comma blanked
357
+ * they were cancelled (ADR-0006, 2026-10-19). A contraction is a negation,
358
+ * `isn't` and `doesn’t`, and so is `cannot`. Tested against the text just
359
+ * before the phrase, which ends with the space or the comma before it.
360
+ */
361
+ const NEGATED_BEFORE = /(?:^|[^\p{L}\p{N}-])(?:no|not|no one|no-one|nobody|none|never|neither|nor|nothing|without|cannot|\p{L}+n['’]t)(?: (?:be|been|being|is|are|was|were|yet|longer|ever|even|currently|actually|really|necessarily|always))* $/u;
362
+ /**
363
+ * A code span that holds nothing but a negation word: `` `none` ``.
364
+ *
365
+ * The structure mask blanks code, which is right for a phrase or a link
366
+ * written in it, and hid the one word a sentence denies with: ``Blocked by
367
+ * `none`: [ADR-7]`` was a `blocked-by` edge where `**none**` was a citation.
368
+ * Read as a negation, a span naming a value called `none` costs a relation
369
+ * the sentence states, which is the miss ADR-0006 prefers to a relation it
370
+ * denies. The backreference matches a closing run as long as the opening,
371
+ * which is what ends a span, and each start is a run's first backtick, so a
372
+ * run is tried once for each length it has: linear in the text.
333
373
  */
334
- const NEGATION = /(?:^|\s)(?:no|not|nobody|none|never|neither|nor|nothing|without)(?![\p{L}\p{N}-])/u;
374
+ const NEGATION_IN_CODE = /(?<!`)(`+) ?(no|not|no-one|nobody|none|never|neither|nor|nothing|without) ?\1(?!`)/giu;
335
375
  /** How close a governing phrase must sit to the reference it governs. */
336
376
  const VERB_WINDOW = 40;
337
377
  /**
@@ -1101,6 +1141,7 @@ function extractReferences(context) {
1101
1141
  // than a typed edge and a neutral citation beside it.
1102
1142
  const linked = new Set();
1103
1143
  const links = referenceLinks(scanned);
1144
+ const words = withNegationsInCode(scanned);
1104
1145
  for (const link of links) {
1105
1146
  if (link.form === 'definition')
1106
1147
  continue;
@@ -1113,7 +1154,7 @@ function extractReferences(context) {
1113
1154
  continue;
1114
1155
  const owner = ownerOf(items, link.start) ?? documentAt(link.start).id;
1115
1156
  const section = sectionPathAt(scanned, link.start);
1116
- const classified = classifyReference(scanned.masks.structure, link.start, link.end, section);
1157
+ const classified = classifyReference(words, link.start, link.end, section);
1117
1158
  linked.add(normaliseRef(stripAnchor(target)));
1118
1159
  out.push({
1119
1160
  kind: classified.kind,
@@ -1136,7 +1177,7 @@ function extractReferences(context) {
1136
1177
  continue;
1137
1178
  const owner = ownerOf(items, bare.start) ?? documentAt(bare.start).id;
1138
1179
  const section = sectionPathAt(scanned, bare.start);
1139
- const classified = classifyReference(scanned.masks.structure, bare.start, bare.end, section);
1180
+ const classified = classifyReference(words, bare.start, bare.end, section);
1140
1181
  out.push({
1141
1182
  kind: classified.kind,
1142
1183
  from: owner,
@@ -1236,7 +1277,7 @@ function ownerOf(items, offset) {
1236
1277
  * neutral citation.
1237
1278
  */
1238
1279
  export function classifyReference(text, start, end, section) {
1239
- const before = sentenceBefore(text, start);
1280
+ const { words: before, commas } = sentenceBefore(text, start);
1240
1281
  // Left to right, so the last match within the window is the nearest one - and
1241
1282
  // the nearest governing phrase is the one that governs. `matchAll`, not a
1242
1283
  // loop over `exec`: an empty match leaves `lastIndex` where it began, and
@@ -1251,9 +1292,13 @@ export function classifyReference(text, start, end, section) {
1251
1292
  if (distance > VERB_WINDOW)
1252
1293
  continue;
1253
1294
  // What sits between the phrase and the reference has to be connective. A
1254
- // negation in there reverses the claim the phrase would otherwise make.
1295
+ // negation in there reverses the claim the phrase would otherwise make,
1296
+ // and so does one just before the phrase, where no comma ends its clause
1297
+ // first.
1255
1298
  if (NEGATION.test(before.slice(end)))
1256
1299
  continue;
1300
+ if (NEGATED_BEFORE.test(commas.slice(0, m.index)))
1301
+ continue;
1257
1302
  // Always found: the pattern was compiled from this table's own keys, and a
1258
1303
  // lower-cased search matches them only as written. The check is for types.
1259
1304
  const rule = VERB_LOOKUP.get(phrase);
@@ -1308,17 +1353,33 @@ const STATEMENT_BREAK = /[.?!;]\s|[。?!;]|\n\s*\n|\n\s*[-*+>#]|\|/g;
1308
1353
  * The search stops at the last boundary: a governing phrase in the *previous*
1309
1354
  * sentence does not govern this reference. "We deferred that to ADR-3. See also
1310
1355
  * [ADR-9]." must not read as a delegation to ADR-9.
1356
+ *
1357
+ * Emphasis, brackets and commas are blanked. A straight quote and a straight
1358
+ * apostrophe are kept, as the curly ones always were, since a negation
1359
+ * before a phrase is read by what stands against it: a quoted `"never"`
1360
+ * names a word, and `isn't` is one word. Kept, each counts toward the
1361
+ * distance a phrase reaches where it stands beside a space, as a curly one
1362
+ * did (ADR-0006, 2026-10-18).
1363
+ *
1364
+ * The same text is given a second time with each run of spaces that held a
1365
+ * comma written as the comma: one character either way, so the two line up
1366
+ * and a comma counts toward no phrase's reach, and a negation before a
1367
+ * phrase is read from the second, where a comma keeps the two apart
1368
+ * (ADR-0006, 2026-10-19).
1311
1369
  */
1312
1370
  function sentenceBefore(text, start) {
1313
1371
  const window = text.slice(Math.max(0, start - 160), start);
1314
1372
  let cut = 0;
1315
1373
  for (const m of window.matchAll(STATEMENT_BREAK))
1316
1374
  cut = m.index + m[0].length;
1317
- return window
1375
+ const statement = window
1318
1376
  .slice(cut)
1319
1377
  .toLowerCase()
1320
- .replace(/[`*_~"'()\[\],]/g, ' ')
1321
- .replace(/\s+/g, ' ');
1378
+ .replace(/[`*_~()\[\]]/g, ' ');
1379
+ return {
1380
+ words: statement.replace(/[\s,]+/g, ' '),
1381
+ commas: statement.replace(/[\s,]+/g, (run) => (run.includes(',') ? ',' : ' ')),
1382
+ };
1322
1383
  }
1323
1384
  /**
1324
1385
  * The statement after a reference, to the first stop, the stops of a sentence
@@ -1336,6 +1397,29 @@ function sentenceAfter(text, end) {
1336
1397
  const cut = stop ? window.slice(0, stop.index) : window;
1337
1398
  return cut.toLowerCase().replace(/[`*_~"'()\[\],]/g, ' ').replace(/\s+/g, ' ').trim();
1338
1399
  }
1400
+ /**
1401
+ * The structure mask, with the word of each code span that holds nothing
1402
+ * but a negation written back where the span was: what a reference's
1403
+ * phrase and its negation are read from.
1404
+ *
1405
+ * A word written back over itself changes nothing, so the backticks the
1406
+ * scanner read as text, and so left in the structure, need no telling apart
1407
+ * from a span. A span inside a code block can be written back too: a block
1408
+ * is blank lines in the structure, which end a statement before any
1409
+ * reference after it reads back so far.
1410
+ */
1411
+ function withNegationsInCode(scanned) {
1412
+ const { structure, prose } = scanned.masks;
1413
+ let text = '';
1414
+ let cursor = 0;
1415
+ for (const m of prose.matchAll(NEGATION_IN_CODE)) {
1416
+ const word = m[2];
1417
+ const at = m.index + m[0].indexOf(word);
1418
+ text += structure.slice(cursor, at) + word;
1419
+ cursor = at + word.length;
1420
+ }
1421
+ return text + structure.slice(cursor);
1422
+ }
1339
1423
  /**
1340
1424
  * Finds identifiers written as plain prose, such as "as decided in ADR-0007".
1341
1425
  *