From 81277d45ca027c675914399ac318888d154b8cc2 Mon Sep 17 00:00:00 2001 From: Kurt Seifried Date: Thu, 5 Nov 2026 00:10:36 -0600 Subject: [PATCH 1/2] fix(resolver): lookup_table precedence, known_values/open_pattern & canonical name extraction - Implement lookup_table precedence over url template in resolveChildUrl and type/namespace-scoped searches, and include lookup_table in has_url index flags (SecID#177). - Close nodeMatches when known_values is a non-empty string[] array (with or without open_pattern: true) or a non-empty Record on an open node, and propagate open to TypeIndex.child_index (SecID#190). - Export single-source-of-truth extractNameSlug(node, matchedName?) that preserves escaped literal dots (^[\w.-]+$) and echoes matchedName for regex source patterns so canonical SecIDs round-trip (SecID#181). Refs: CloudSecurityAlliance/SecID#177 Refs: CloudSecurityAlliance/SecID#181 Refs: CloudSecurityAlliance/SecID#190 AI-Agent: Gemini --- scripts/upload-registry-kv.ts | 23 +--- src/kv-resolve.ts | 4 +- src/resolver.ts | 113 ++++++++++-------- src/types.ts | 23 ++-- test/helpers/seed-kv.ts | 19 +--- test/resolver-correctness.test.ts | 183 +++++++++++++++++++++++++++++- test/resolver.test.ts | 12 +- 7 files changed, 271 insertions(+), 106 deletions(-) diff --git a/scripts/upload-registry-kv.ts b/scripts/upload-registry-kv.ts index 26b919b..850d92b 100644 --- a/scripts/upload-registry-kv.ts +++ b/scripts/upload-registry-kv.ts @@ -30,7 +30,7 @@ import { fileURLToPath } from "url"; import { homedir } from "os"; import { execFileSync } from "child_process"; import { namespaceAliases } from "../src/identity"; -import { isOpenPattern } from "../src/resolver"; +import { extractNameSlug, isOpenPattern } from "../src/resolver"; import type { GlobalChildIndexEntry } from "../src/types"; const __filename = fileURLToPath(import.meta.url); @@ -74,6 +74,7 @@ interface MatchNodeRaw { interface ChildIndexEntry { namespace: string; name_slug: string; + open?: boolean; patterns: string[]; description: string; weight: number; @@ -134,21 +135,6 @@ function findJsonFiles(dir: string): string[] { return results; } -// ── Name Slug Extraction ── - -function extractNameSlug(node: MatchNodeRaw): string { - const pat = node.patterns[0] ?? ""; - const cleaned = pat - .replace(/^\(\?i\)/i, "") - .replace(/^\^/, "") - .replace(/\$$/, "") - .replace(/\\(.)/g, "$1"); // Unescape: \- → -, \. → . - if (/^[\w-]+$/.test(cleaned)) { - return cleaned.toLowerCase(); - } - return node.description.toLowerCase().replace(/\s+/g, "-"); -} - // ── Build KV Entries ── function buildEntries(): BulkEntry[] { @@ -269,10 +255,11 @@ function buildEntries(): BulkEntry[] { childIndex.push({ namespace: ns, name_slug: nameSlug, + open: child.open_pattern === true || isOpenPattern(child.patterns), patterns: child.patterns, description: child.description, weight: child.weight, - has_url: !!child.data?.url, + has_url: !!(child.data?.url || child.data?.lookup_table), }); } } @@ -332,7 +319,7 @@ function buildEntries(): BulkEntry[] { patterns: child.patterns, description: child.description, weight: child.weight, - has_url: !!child.data?.url, + has_url: !!(child.data?.url || child.data?.lookup_table), }); } } diff --git a/src/kv-resolve.ts b/src/kv-resolve.ts index c179e20..4ad9398 100644 --- a/src/kv-resolve.ts +++ b/src/kv-resolve.ts @@ -388,8 +388,8 @@ async function crossSourceCandidates( name: string, typeIndex: TypeIndex ): Promise { - const tight = typeIndex.child_index.filter((e) => !isOpenPattern(e.patterns)); - const open = typeIndex.child_index.filter((e) => isOpenPattern(e.patterns)); + const tight = typeIndex.child_index.filter((e) => !(e.open ?? isOpenPattern(e.patterns))); + const open = typeIndex.child_index.filter((e) => e.open ?? isOpenPattern(e.patterns)); const tightMatches = findMatchingNamespaces(name, tight); if (tightMatches.length > 0) return tightMatches; diff --git a/src/resolver.ts b/src/resolver.ts index b63c890..428f6c6 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -305,8 +305,8 @@ function matchVersionAlias(node: MatchNode, version: string): VersionAliasMatch * plain literal (SecID spec ADR-015: a version node's patterns[0] is the canonical form). * `(?i)^1\.1\.1$` → "1.1.1". */ -function patternLiteral(pat: string): string | null { - const body = pat.replace(/^\(\?i\)/, "").replace(/^\^/, "").replace(/\$$/, ""); +export function patternLiteral(pat: string): string | null { + const body = pat.replace(/^\(\?i\)/i, "").replace(/^\^/, "").replace(/\$$/, ""); let out = ""; for (let i = 0; i < body.length; i++) { const ch = body[i]; @@ -331,7 +331,7 @@ function versionRedirect( canonical: ParsedSecID, node: MatchNode ): ResolveResponse { - const base = `secid:${canonical.type}/${canonical.namespace}/${extractNameSlug(node)}@${canonical.version}`; + const base = `secid:${canonical.type}/${canonical.namespace}/${extractNameSlug(node, canonical.name ?? undefined)}@${canonical.version}`; const target = canonical.subpath ? `${base}#${canonical.subpath}` : base; return response( query, @@ -368,7 +368,7 @@ function describeVersions(node: MatchNode): string { * versions and where to report a missing release. */ function versionNotFound(query: string, parsed: ParsedSecID, node: MatchNode): ResolveResponse { - const slug = extractNameSlug(node); + const slug = extractNameSlug(node, parsed.name ?? undefined); return response( query, "not_found", @@ -436,7 +436,7 @@ function resolveVersionMismatch( const r = withVersion(null); if (listed.length > 0) { if (r.status === "found" || r.status === "corrected") r.status = "related"; - const note = `Version "${version}" not found for ${extractNameSlug(node)}. Known versions: ${describeVersions(node)}. Showing the source without a version.`; + const note = `Version "${version}" not found for ${extractNameSlug(node, parsed.name ?? undefined)}. Known versions: ${describeVersions(node)}. Showing the source without a version.`; r.message = r.message ? `${note} ${r.message}` : note; return r; } @@ -457,7 +457,7 @@ function versionRequiredMiss( parsed: ParsedSecID, node: MatchNode ): ResolveResponse { - const nameSlug = extractNameSlug(node); + const nameSlug = extractNameSlug(node, parsed.name ?? undefined); return response( query, "related", @@ -498,7 +498,7 @@ function describeSource( })); } - const nameSlug = extractNameSlug(node); + const nameSlug = extractNameSlug(node, parsed.name ?? undefined); const secid = parsed.version ? `secid:${parsed.type}/${parsed.namespace}/${nameSlug}@${parsed.version}` : `secid:${parsed.type}/${parsed.namespace}/${nameSlug}`; @@ -575,7 +575,7 @@ function resolveVersioned( unversioned_behavior: node.data.unversioned_behavior ?? null, }; - const nameSlug = extractNameSlug(node); + const nameSlug = extractNameSlug(node, parsed.name ?? undefined); return response( query, "related", @@ -599,7 +599,7 @@ function resolveVersioned( // No subpath — describe version if (!parsed.subpath) { - const nameSlug = extractNameSlug(node); + const nameSlug = extractNameSlug(node, parsed.name ?? undefined); const data: Record = { official_name: versionChild.data.official_name ?? versionChild.description, urls: versionChild.data.urls ?? [], @@ -682,12 +682,13 @@ function matchChildrenAndResolve( } } - const nameSlug = extractNameSlug(slugNode); + const nameSlug = extractNameSlug(slugNode, parsed.name ?? undefined); const secid = parsed.version ? `secid:${parsed.type}/${parsed.namespace}/${nameSlug}@${parsed.version}#${subpath}` : `secid:${parsed.type}/${parsed.namespace}/${nameSlug}#${subpath}`; - // Try to build a URL (parentNode has the notes for variable extraction) + // Try to build a URL (lookup_table takes precedence over url template; + // parentNode has the notes for variable extraction) const url = resolveChildUrl(child, subpath, parentNode, qualifiers); if (url) { @@ -705,28 +706,15 @@ function matchChildrenAndResolve( results.push(res); } else if (child.data.lookup_table) { - // Lookup table: try direct key match - // Own-property check: a subpath like "constructor" must not resolve to - // an Object.prototype member. - const entry = Object.hasOwn(child.data.lookup_table, subpath) - ? child.data.lookup_table[subpath] - : undefined; - if (entry) { - const lookupUrl = typeof entry === "string" ? entry : entry.url; - const res: ResolutionResult = { secid, weight: child.weight, url: lookupUrl }; - if (child.data.content_type) res.content_type = child.data.content_type; - addFormatMetadata(res, child.data); - results.push(res); - } else { - // Return the lookup_table as registry data - results.push({ - secid, - data: { - description: child.description, - available_items: Object.keys(child.data.lookup_table), - }, - } as RegistryResult); - } + // Child has a lookup_table (and no fallback url template), and subpath was + // not a key in lookup_table — return available_items as registry data. + results.push({ + secid, + data: { + description: child.description, + available_items: Object.keys(child.data.lookup_table), + }, + } as RegistryResult); } else { // No URL pattern — return as registry data results.push({ @@ -763,6 +751,16 @@ function resolveChildUrl( parentNode: MatchNode, qualifiers?: Record | null ): string | null { + // Per REGISTRY-JSON-FORMAT.md (#177): if a node has both lookup_table and url, + // lookup_table takes priority for IDs it contains; url is the fallback for + // matching IDs not in the table. Own-property check avoids Object.prototype hits. + if (child.data.lookup_table && Object.hasOwn(child.data.lookup_table, subpath)) { + const entry = child.data.lookup_table[subpath]; + if (entry) { + return typeof entry === "string" ? entry : entry.url; + } + } + const urlTemplate = child.data.url; if (!urlTemplate) return null; @@ -1059,7 +1057,7 @@ function typeScopedSearch( // (e.g., "cwe" matches the cwe source in weakness/mitre.org). // Return source-level info rather than a child resolution. if (nodeMatches(node, identifier, false)) { - const nameSlug = extractNameSlug(node); + const nameSlug = extractNameSlug(node, identifier); const secid = `secid:${parsed.type}/${nsKey}/${nameSlug}`; // Prefer source-level url; fall back to first namespace url; otherwise // emit a RegistryResult with description so the consumer at least gets @@ -1256,9 +1254,20 @@ function isNodeOpen(node: MatchNode): boolean { } function nodeMatches(node: MatchNode, input: string, scoped: boolean): boolean { + const known = node.data?.known_values; + // A string[] known_values array has no descriptions — its sole role in the + // schema is to enumerate the closed set of valid identifiers (#190). + if (Array.isArray(known)) { + if (known.length > 0) { + return known.includes(input) && matchesAnyPattern(node.patterns, input); + } + if (isNodeOpen(node) && !scoped) return false; + return matchesAnyPattern(node.patterns, input); + } if (isNodeOpen(node)) { - const known = node.data?.known_values; - if (known) return Object.prototype.hasOwnProperty.call(known, input); + if (known && Object.keys(known).length > 0) { + return Object.hasOwn(known, input) && matchesAnyPattern(node.patterns, input); + } if (!scoped) return false; } return matchesAnyPattern(node.patterns, input); @@ -1301,20 +1310,26 @@ export function toRegExp(pat: string): RegExp { return re; } -function extractNameSlug(node: MatchNode): string { - // Extract a human-readable name from the first pattern - // Patterns like "(?i)^cve$" → "cve", "(?i)^cna\-tlr$" → "cna-tlr" - const pat = node.patterns[0] ?? ""; - const cleaned = pat - .replace(/^\(\?i\)/i, "") - .replace(/^\^/, "") - .replace(/\$$/, "") - .replace(/\\(.)/g, "$1"); // Unescape: \- → -, \. → . - // If it's a simple literal (no regex meta), use it - if (/^[\w-]+$/.test(cleaned)) { - return cleaned.toLowerCase(); +export function extractNameSlug( + node: { patterns: string[]; description: string }, + matchedName?: string +): string { + // Extract a human-readable canonical name from the first pattern when it is a + // plain literal (e.g., "(?i)^cve$" → "cve", "(?i)^cna\-tlr$" → "cna-tlr", + // "(?i)^x\.805$" → "x.805"). Unescaped '.' is rejected by patternLiteral as a + // regex wildcard while escaped '\.' is preserved (#181). + const lit = patternLiteral(node.patterns[0] ?? ""); + if (lit && /^[\w.-]+$/.test(lit)) { + return lit.toLowerCase(); + } + // When patterns[0] is a regex template (numbered series like GLI-19 or + // direct identifier patterns like arXiv IDs and DOIs) and the caller matched + // a specific name, echo that matched name so the emitted SecID round-trips + // (#181). + if (matchedName) { + return matchedName; } - // Fallback: use description + // Fallback for static listings (Level 2 / index build) where no query name matched return node.description.toLowerCase().replace(/\s+/g, "-"); } diff --git a/src/types.ts b/src/types.ts index bab6b2d..c5c487d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,11 +79,12 @@ export interface MatchNodeData { type?: string; note?: string; /** - * Enumeration of the identifiers this node accepts, id → title. Treated as a - * closed set only when `patterns` is open (see isOpenPattern) — several - * registry entries pair a tight pattern with a deliberately partial list. + * Enumeration of the identifiers this node accepts, either as an id → title + * map or a flat array of valid IDs. String arrays always close the set; object + * maps close the set when `patterns` is open (see isOpenPattern) — several + * registry entries pair a tight pattern with a deliberately partial map. */ - known_values?: Record; + known_values?: Record | string[]; variables?: Record; lookup_table?: Record; @@ -202,6 +203,13 @@ export interface ChildIndexEntry { * absent as "child" for backward compatibility. */ level?: "source" | "child"; + /** + * This node accepts arbitrary input — declared via open_pattern or detected + * from the regex. Bare-identifier search skips these: an unbounded pattern + * cannot tell a real identifier from a search term. Absent on deploys + * predating this field, so callers must fall back to detection. + */ + open?: boolean; patterns: string[]; description: string; weight: number; @@ -210,13 +218,6 @@ export interface ChildIndexEntry { // Combined child_index across all types, stored under KV key "secid:*" export interface GlobalChildIndexEntry extends ChildIndexEntry { - /** - * This node accepts arbitrary input — declared via open_pattern or detected - * from the regex. Bare-identifier search skips these: an unbounded pattern - * cannot tell a real identifier from a search term. Absent on deploys - * predating this field, so callers must fall back to detection. - */ - open?: boolean; type: SecIDType; } diff --git a/test/helpers/seed-kv.ts b/test/helpers/seed-kv.ts index af82005..b56bee6 100644 --- a/test/helpers/seed-kv.ts +++ b/test/helpers/seed-kv.ts @@ -8,7 +8,7 @@ import { REGISTRY } from "../../src/registry"; import type { ChildIndexEntry, GlobalChildIndexEntry, NameIndexEntry, TypeIndex } from "../../src/types"; import { namespaceAliases } from "../../src/identity"; -import { isOpenPattern } from "../../src/resolver"; +import { extractNameSlug, isOpenPattern } from "../../src/resolver"; import { TYPE_SHORT_DESCRIPTIONS } from "../../src/type-registry"; interface MatchNodeLike { @@ -20,18 +20,6 @@ interface MatchNodeLike { children?: MatchNodeLike[]; } -function extractNameSlug(node: MatchNodeLike): string { - const pat = node.patterns[0] ?? ""; - const cleaned = pat - .replace(/^\(\?i\)/i, "") - .replace(/^\^/, "") - .replace(/\$$/, ""); - if (/^[\w-]+$/.test(cleaned)) { - return cleaned.toLowerCase(); - } - return node.description.toLowerCase().replace(/\s+/g, "-"); -} - export async function seedRegistryKV(kv: KVNamespace): Promise { const typeCounts: Record = {}; // Combined index across all types — must mirror the production upload script @@ -70,10 +58,11 @@ export async function seedRegistryKV(kv: KVNamespace): Promise { childIndex.push({ namespace: ns, name_slug: nameSlug, + open: child.open_pattern === true || isOpenPattern(child.patterns), patterns: child.patterns, description: child.description, weight: child.weight, - has_url: !!child.data?.url, + has_url: !!(child.data?.url || child.data?.lookup_table), }); } } @@ -102,7 +91,7 @@ export async function seedRegistryKV(kv: KVNamespace): Promise { patterns: child.patterns, description: child.description, weight: child.weight, - has_url: !!child.data?.url, + has_url: !!(child.data?.url || child.data?.lookup_table), }); } } diff --git a/test/resolver-correctness.test.ts b/test/resolver-correctness.test.ts index d0e065d..238b197 100644 --- a/test/resolver-correctness.test.ts +++ b/test/resolver-correctness.test.ts @@ -6,7 +6,7 @@ import { SELF, env } from "cloudflare:test"; import { seedRegistryKV } from "./helpers/seed-kv"; import { resolveFromKV, resolveQuery } from "../src/kv-resolve"; import { parseSecID } from "../src/parser"; -import { resolve, toRegExp } from "../src/resolver"; +import { resolve, toRegExp, extractNameSlug } from "../src/resolver"; import { REGISTRY } from "../src/registry"; import type { Registry, RegistryNamespace, ResolveResponse, TypeIndex } from "../src/types"; @@ -370,3 +370,184 @@ describe("pattern compilation cache (L2)", () => { expect(() => toRegExp("(")).toThrow(); }); }); + +describe("lookup_table precedence over url template (SecID#177)", () => { + const registry: Registry = { + reference: { + "test.example": fakeNamespace([ + { + patterns: ["(?i)^spdx$"], + description: "SPDX test", + weight: 100, + data: {}, + children: [ + { + patterns: ["^(Core|Software|AI)$"], + description: "SPDX 3.0.1 profile namespace", + weight: 100, + data: { + url: "https://spdx.org/rdf/3.0.1/terms/{id}/{id}", + lookup_table: { + Core: "https://spdx.org/rdf/3.0.1/terms/Core", + }, + }, + }, + { + patterns: ["^(alpha|beta)$"], + description: "Lookup-only item", + weight: 90, + data: { + lookup_table: { + alpha: "https://test.example/lookup/alpha", + beta: "https://test.example/lookup/beta", + }, + }, + }, + ], + }, + ]), + }, + }; + + it("prefers lookup_table over url template when key is in lookup_table", () => { + const r = resolve(parseSecID("secid:reference/test.example/spdx#Core", registry), registry); + expect(r.status).toBe("found"); + expect((r.results[0] as { url: string }).url).toBe("https://spdx.org/rdf/3.0.1/terms/Core"); + }); + + it("falls back to url template when key matches pattern but is not in lookup_table", () => { + const r = resolve(parseSecID("secid:reference/test.example/spdx#AI", registry), registry); + expect(r.status).toBe("found"); + expect((r.results[0] as { url: string }).url).toBe("https://spdx.org/rdf/3.0.1/terms/AI/AI"); + }); + + it("resolves lookup_table-only nodes in namespace-scoped and type-scoped searches", () => { + const nsScoped = resolve(parseSecID("secid:reference/test.example/alpha", registry), registry); + expect(nsScoped.status).toBe("corrected"); + expect((nsScoped.results[0] as { url: string }).url).toBe("https://test.example/lookup/alpha"); + + const typeScoped = resolve(parseSecID("secid:reference/alpha", registry), registry); + expect(typeScoped.status).toBe("found"); + expect((typeScoped.results[0] as { url: string }).url).toBe("https://test.example/lookup/alpha"); + }); +}); + +describe("known_values array closing and open_pattern behavior (SecID#190)", () => { + const registry: Registry = { + control: { + "disa.example": { + ...fakeNamespace([ + { + patterns: ["(?i)^rhel9-stig$"], + description: "RHEL 9 STIG", + weight: 100, + data: {}, + children: [ + { + patterns: ["^V-\\d+$"], + description: "STIG V-ID (without open_pattern)", + weight: 90, + data: { + url: "https://disa.example/rhel9/{id}", + known_values: ["V-257777", "V-257778"], + }, + }, + { + patterns: ["^SV-\\d+r\\d+_rule$"], + description: "STIG SV-ID (with open_pattern: true)", + weight: 90, + open_pattern: true, + data: { + url: "https://disa.example/rhel9/{id}", + known_values: ["SV-257777r925318_rule"], + }, + }, + { + patterns: ["^OPEN-\\d+$"], + description: "Open numeric rule without known_values", + weight: 50, + open_pattern: true, + data: { + url: "https://disa.example/open/{id}", + }, + }, + ], + }, + ]), + namespace: "disa.example", + type: "control", + }, + }, + }; + + it("closes pattern when known_values is a string[] array (without open_pattern)", () => { + const hitScoped = resolve(parseSecID("secid:control/disa.example/rhel9-stig#V-257777", registry), registry); + expect(hitScoped.status).toBe("found"); + expect(secids(hitScoped)).toEqual(["secid:control/disa.example/rhel9-stig#V-257777"]); + + const missScoped = resolve(parseSecID("secid:control/disa.example/rhel9-stig#V-999999", registry), registry); + expect(missScoped.status).toBe("related"); + + const hitUnscoped = resolve(parseSecID("secid:control/V-257777", registry), registry); + expect(hitUnscoped.status).toBe("found"); + expect(secids(hitUnscoped)).toEqual(["secid:control/disa.example/rhel9-stig#V-257777"]); + + const missUnscoped = resolve(parseSecID("secid:control/V-999999", registry), registry); + expect(missUnscoped.status).toBe("not_found"); + }); + + it("closes pattern when known_values is a string[] array alongside open_pattern: true", () => { + const hitScoped = resolve( + parseSecID("secid:control/disa.example/rhel9-stig#SV-257777r925318_rule", registry), + registry, + ); + expect(hitScoped.status).toBe("found"); + + const missScoped = resolve( + parseSecID("secid:control/disa.example/rhel9-stig#SV-999999r1_rule", registry), + registry, + ); + expect(missScoped.status).toBe("related"); + }); + + it("excludes open_pattern: true without known_values from unscoped search, but resolves when scoped", () => { + const missUnscoped = resolve(parseSecID("secid:control/OPEN-123", registry), registry); + expect(missUnscoped.status).toBe("not_found"); + + const hitScoped = resolve(parseSecID("secid:control/disa.example/rhel9-stig#OPEN-123", registry), registry); + expect(hitScoped.status).toBe("found"); + expect(secids(hitScoped)).toEqual(["secid:control/disa.example/rhel9-stig#OPEN-123"]); + }); +}); + +describe("extractNameSlug canonical name extraction (SecID#181)", () => { + it("preserves escaped dots in literal patterns[0] while rejecting unescaped wildcard dots", () => { + expect( + extractNameSlug({ + patterns: ["(?i)^x\\.805$"], + description: "ITU-T X.805 Security architecture", + weight: 85, + data: {}, + }), + ).toBe("x.805"); + + expect( + extractNameSlug({ + patterns: ["(?i)^x.805$"], + description: "ITU-T X.805 Wildcard dot", + weight: 85, + data: {}, + }), + ).toBe("itu-t-x.805-wildcard-dot"); + }); + + it("echoes matchedName when patterns[0] is a regex template so canonical SecIDs round-trip", () => { + const gli = resolveLocal("secid:control/gaminglabs.com/GLI-19"); + expect(gli.status).toBe("found"); + expect(secids(gli)[0]).toBe("secid:control/gaminglabs.com/GLI-19"); + + const roundTrip = resolveLocal(secids(gli)[0]); + expect(roundTrip.status).toBe("found"); + expect(secids(roundTrip)[0]).toBe("secid:control/gaminglabs.com/GLI-19"); + }); +}); diff --git a/test/resolver.test.ts b/test/resolver.test.ts index 86eabe3..31fd67f 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { parseSecID } from "../src/parser"; -import { resolve, buildUrl } from "../src/resolver"; +import { resolve, buildUrl, extractNameSlug } from "../src/resolver"; import { REGISTRY } from "../src/registry"; import type { ResolutionResult, RegistryResult, ResolveResponse, MatchNode, ExampleObject } from "../src/types"; @@ -324,15 +324,7 @@ describe("content_type on results", () => { // ── Helper ── function extractTestNameSlug(node: MatchNode): string { - const pat = node.patterns[0] ?? ""; - const cleaned = pat - .replace(/^\(\?i\)/i, "") - .replace(/^\^/, "") - .replace(/\$$/, ""); - if (/^[\w-]+$/.test(cleaned)) { - return cleaned.toLowerCase(); - } - return node.description.toLowerCase().replace(/\s+/g, "-"); + return extractNameSlug(node); } // ── Resolver-side URL validation (F-01-01) ── From 5fa7c2eeddb1abf62e992152b94e70bec74ad350 Mon Sep 17 00:00:00 2001 From: Kurt Seifried Date: Thu, 5 Nov 2026 00:15:09 -0600 Subject: [PATCH 2/2] test(resolver): remove excess properties from extractNameSlug test inputs AI-Agent: Gemini --- test/resolver-correctness.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/resolver-correctness.test.ts b/test/resolver-correctness.test.ts index 238b197..bb3695f 100644 --- a/test/resolver-correctness.test.ts +++ b/test/resolver-correctness.test.ts @@ -526,8 +526,6 @@ describe("extractNameSlug canonical name extraction (SecID#181)", () => { extractNameSlug({ patterns: ["(?i)^x\\.805$"], description: "ITU-T X.805 Security architecture", - weight: 85, - data: {}, }), ).toBe("x.805"); @@ -535,8 +533,6 @@ describe("extractNameSlug canonical name extraction (SecID#181)", () => { extractNameSlug({ patterns: ["(?i)^x.805$"], description: "ITU-T X.805 Wildcard dot", - weight: 85, - data: {}, }), ).toBe("itu-t-x.805-wildcard-dot"); });