Skip to content
Merged
4 changes: 2 additions & 2 deletions lib/config/rfc-status-hierarchy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ export const rfcStatusHierarchy = [
]

/**
* Extracts the highest status weight based on RFC status hierarchy.
* Extracts the highest status category based on RFC status hierarchy.
*
* @param {string} statusText - The status text to check.
* @returns {number|null} - The weight of the status or null if not found.
* @returns {number|null} - The category of the status or null if not found.
*/
export function getStatusCategory (statusText) {
for (const status of rfcStatusHierarchy) {
Expand Down
5 changes: 4 additions & 1 deletion lib/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ import {
validateCodeComments
} from './modules/txt.mjs'
import {
validateDownrefs
validateDownrefs,
validateNormativeReferences
} from './modules/downref.mjs'

/**
Expand Down Expand Up @@ -139,6 +140,8 @@ export async function checkNits (raw, filename, {
result.push(...await validateVersion(doc, { mode, offline }))
progressReport('Validating downrefs in text...')
result.push(...await validateDownrefs(doc, { mode }))
progressReport('Validating normative references statuses')
result.push(...(await validateNormativeReferences(doc, { mode })))

// Run XML-only validations
if (doc.type === 'xml') {
Expand Down
105 changes: 100 additions & 5 deletions lib/modules/downref.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ValidationWarning, ValidationError } from '../helpers/error.mjs'
import { ValidationWarning, ValidationError, ValidationComment } from '../helpers/error.mjs'
import { checkReferencesInDownrefs } from '../remote/downref.mjs'
import { MODES } from '../config/modes.mjs'
import { findAllDescendantsWith } from '../helpers/traversal.mjs'
import { fetchRemoteDocInfoJson, fetchRemoteRfcInfo } from '../helpers/remote.mjs'
import { getStatusCategory } from '../config/rfc-status-hierarchy.mjs'
import { fetchRemoteDocInfoJson, fetchRemoteRfcInfo } from '../helpers/remote.mjs'
import { findAllDescendantsWith } from '../helpers/traversal.mjs'

/**
* Validate document references for RFCs and Drafts downrefs.
Expand All @@ -24,7 +24,7 @@ export async function validateDownrefs (doc, { mode = MODES.NORMAL } = {}) {
switch (doc.type) {
case 'txt': {
const { referenceSectionRfc, referenceSectionDraftReferences } = doc.data.extractedElements
const statusWeight = getStatusCategory(doc.data.header.intendedStatus ?? doc.data.header.category)
const statusCategory = getStatusCategory(doc.data.header.intendedStatus ?? doc.data.header.category)
const rfcs = referenceSectionRfc.filter((extracted) => extracted.subsection === 'normative_references').map((extracted) => extracted.value).map((rfcNumber) => `RFC ${rfcNumber}`)
const drafts = normalizeDraftReferences(referenceSectionDraftReferences.filter((extracted) => extracted.subsection === 'normative_references').map((extracted) => extracted.value))
for (const ref of [...rfcs, ...drafts]) {
Expand All @@ -39,7 +39,7 @@ export async function validateDownrefs (doc, { mode = MODES.NORMAL } = {}) {
refStatus = getStatusCategory(draftInfo?.intended_std_level || draftInfo?.std_level)
}

if (refStatus !== null && refStatus < statusWeight) {
if (refStatus !== null && refStatus < statusCategory) {
const isDownref = await checkReferencesInDownrefs([ref])
if (isDownref.length > 0) {
switch (mode) {
Expand Down Expand Up @@ -190,3 +190,98 @@ function normalizeXmlReferences (references) {

return normalizedReferences
}

/**
* Validates normative references within a document by checking the status of referenced RFCs.
*
* This function processes both text (`txt`) and XML (`xml`) documents, identifying RFCs within
* normative references and validating their status using remote data fetched from the RFC editor.
*
* - For TXT documents, it looks for normative references in the `referenceSectionRfc` field.
* - For XML documents, it extracts references from the back references section.
*
* Steps:
* 1. Extract normative references for both TXT and XML documents.
* 2. Fetch metadata for each RFC using `fetchRemoteRfcInfo`.
* 3. Validate the fetched status:
* - If no status is defined or the RFC cannot be fetched, a `UNDEFINED_STATUS` comment is added.
* - If the status is unrecognized, an `UNKNOWN_STATUS` comment is added.
* 4. Return a list of validation comments highlighting issues.
*
* @param {Object} doc - The document to validate.
* @param {Object} [opts] - Additional options.
* @param {number} [opts.mode=MODES.NORMAL] - Validation mode (e.g., NORMAL, SUBMISSION).
* @returns {Promise<Array>} - A list of validation results, including warnings or comments.
*/
export async function validateNormativeReferences (doc, { mode = MODES.NORMAL } = {}) {
const result = []
const RFC_NUMBER_REG = /^\d+$/

if (mode === MODES.SUBMISSION) {
return result
}

switch (doc.type) {
case 'txt': {
const normativeReferences = doc.data.extractedElements.referenceSectionRfc
.filter((el) => el.subsection === 'normative_references' && RFC_NUMBER_REG.test(el.value))
.map((el) => el.value)

for (const rfcNum of normativeReferences) {
const rfcInfo = await fetchRemoteRfcInfo(rfcNum)

if (!rfcInfo || !rfcInfo.status) {
result.push(new ValidationComment('UNDEFINED_STATUS', `RFC ${rfcNum} does not have a defined status or could not be fetched.`, {
ref: `https://www.rfc-editor.org/info/rfc${rfcNum}`
}))
continue
}

const statusCategory = getStatusCategory(rfcInfo.status)

if (statusCategory === null) {
result.push(new ValidationComment('UNKNOWN_STATUS', `RFC ${rfcNum} has an unrecognized status: "${rfcInfo.status}".`, {
ref: `https://www.rfc-editor.org/info/rfc${rfcNum}`
}))
}
}
break
}
case 'xml': {
const referencesSections = doc.data.rfc.back.references.references
const normativeReferencesSection = referencesSections.find(section =>
section.name?.toLowerCase().includes('normative references')
)
const normativeReferences = normativeReferencesSection
? findAllDescendantsWith(normativeReferencesSection, (value, key) => key === '_attr' && value.anchor)
.flatMap((match) => (Array.isArray(match.value.anchor) ? match.value.anchor : [match.value.anchor]))
.filter(Boolean)
: []
const normilizedReferences = normalizeXmlReferences(normativeReferences)
.filter((ref) => ref.startsWith('RFC'))
.map((ref) => ref.match(/\d+/)[0])

for (const rfcNum of normilizedReferences) {
const rfcInfo = await fetchRemoteRfcInfo(rfcNum)

if (!rfcInfo || !rfcInfo.status) {
result.push(new ValidationComment('UNDEFINED_STATUS', `RFC ${rfcNum} does not have a defined status or could not be fetched.`, {
ref: `https://www.rfc-editor.org/info/rfc${rfcNum}`
}))
continue
}

const statusCategory = getStatusCategory(rfcInfo.status)

if (statusCategory === null) {
result.push(new ValidationComment('UNKNOWN_STATUS', `RFC ${rfcNum} has an unrecognized status: "${rfcInfo.status}".`, {
ref: `https://www.rfc-editor.org/info/rfc${rfcNum}`
}))
}
}
break
}
}

return result
}
123 changes: 120 additions & 3 deletions tests/downref.test.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { describe, expect, test } from '@jest/globals'
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'
import { MODES } from '../lib/config/modes.mjs'
import { toContainError, ValidationWarning, ValidationError } from '../lib/helpers/error.mjs'
import { toContainError, ValidationWarning, ValidationError, ValidationComment } from '../lib/helpers/error.mjs'
import { baseXMLDoc, baseTXTDoc } from './fixtures/base-doc.mjs'
import { cloneDeep, set } from 'lodash-es'
import { validateDownrefs } from '../lib/modules/downref.mjs'
import { validateDownrefs, validateNormativeReferences } from '../lib/modules/downref.mjs'
import fetchMock from 'jest-fetch-mock'

expect.extend({
toContainError
})

beforeEach(() => {
fetchMock.enableMocks()
})

afterEach(() => {
fetchMock.resetMocks()
})

describe('validateDownrefs', () => {
beforeEach(() => {
fetchMock.disableMocks()
})

describe('TXT Document Type', () => {
test('valid references with no downrefs', async () => {
const doc = cloneDeep(baseTXTDoc)
Expand Down Expand Up @@ -139,3 +152,107 @@ describe('validateDownrefs', () => {
})
})
})

describe('validateNormativeReferences', () => {
describe('TXT Document Type', () => {
test('valid normative references', async () => {
const doc = cloneDeep(baseTXTDoc)
set(doc, 'data.extractedElements.referenceSectionRfc', [
{ value: '4086', subsection: 'normative_references' },
{ value: '8141', subsection: 'normative_references' }
])

fetchMock.mockResponse(JSON.stringify({ status: 'Proposed Standard' }))

const result = await validateNormativeReferences(doc, { mode: MODES.NORMAL })
expect(result).toHaveLength(0)
})

test('normative reference with undefined status', async () => {
const doc = cloneDeep(baseTXTDoc)
set(doc, 'data.extractedElements.referenceSectionRfc', [
{ value: '4086', subsection: 'normative_references' }
])

fetchMock.mockResponse(JSON.stringify({}))

const result = await validateNormativeReferences(doc, { mode: MODES.NORMAL })
expect(result).toEqual([
new ValidationComment(
'UNDEFINED_STATUS',
'RFC 4086 does not have a defined status or could not be fetched.',
{ ref: 'https://www.rfc-editor.org/info/rfc4086' }
)
])
})

test('normative reference with unknown status', async () => {
const doc = cloneDeep(baseTXTDoc)
set(doc, 'data.extractedElements.referenceSectionRfc', [
{ value: '8141', subsection: 'normative_references' }
])

fetchMock.mockResponse(JSON.stringify({ status: 'Unknown Status' }))

const result = await validateNormativeReferences(doc, { mode: MODES.NORMAL })
expect(result).toEqual([
new ValidationComment(
'UNKNOWN_STATUS',
'RFC 8141 has an unrecognized status: "Unknown Status".',
{ ref: 'https://www.rfc-editor.org/info/rfc8141' }
)
])
})
})

describe('XML Document Type', () => {
test('valid normative references', async () => {
const doc = cloneDeep(baseXMLDoc)
set(doc, 'data.rfc.back.references.references', [
{ reference: [{ _attr: { anchor: 'RFC4086' } }] },
{ reference: [{ _attr: { anchor: 'RFC8141' } }] }
])

fetchMock.mockResponse(JSON.stringify({ status: 'Proposed Standard' }))

const result = await validateNormativeReferences(doc, { mode: MODES.NORMAL })
expect(result).toHaveLength(0)
})

test('normative reference with undefined status', async () => {
const doc = cloneDeep(baseXMLDoc)
set(doc, 'data.rfc.back.references.references', [
{ name: 'Normative references', reference: [{ _attr: { anchor: 'RFC4086' } }] }
])

fetchMock.mockResponse(JSON.stringify({}))

const result = await validateNormativeReferences(doc, { mode: MODES.NORMAL })
expect(result).toEqual([
new ValidationComment(
'UNDEFINED_STATUS',
'RFC 4086 does not have a defined status or could not be fetched.',
{ ref: 'https://www.rfc-editor.org/info/rfc4086' }
)
])
})

test('normative reference with unknown status', async () => {
const doc = cloneDeep(baseXMLDoc)
set(doc, 'data.rfc.back.references.references', [
{ name: 'Normative references', reference: [{ _attr: { anchor: 'RFC8141' } }] }
])

fetchMock.mockResponse(JSON.stringify({ status: 'Unknown Status' }))

const result = await validateNormativeReferences(doc, { mode: MODES.NORMAL })
expect(result).toEqual([
new ValidationComment(
'UNKNOWN_STATUS',
'RFC 8141 has an unrecognized status: "Unknown Status".',
{ ref: 'https://www.rfc-editor.org/info/rfc8141' }
)
])
})
})
})