fix(r): read the DESCRIPTION Package field past a byte-order mark

A UTF-8 byte-order mark before `Package:` hid the package, so its qualified
calls were dropped as external. And `\s*` crossed a newline, so an empty
`Package:` field captured the next line's `Version:` as the package name.
Strip the mark and keep the value on the field's own line.
This commit is contained in:
Mick Cooney 2026-10-16 17:15:30 +01:00
parent 2566751ba1
commit 76acfd2b59
2 changed files with 61 additions and 5 deletions

View file

@ -237,6 +237,16 @@ function preferredPackageDir(pkgDir: string): string {
/** Global directory budget for {@link hidesUndiscoveredPackage} across all its roots. */
const HIDDEN_SCAN_BUDGET = 5000;
/**
* The `Package:` field of a `DESCRIPTION` file's text, or `undefined` when it has none. A leading
* UTF-8 byte-order mark is not part of the first field name, and a field with an empty value
* names nothing: `[ \t]*`, not `\s*`, so the value never begins on the next line.
*/
function descriptionPackageName(content: string): string | undefined {
const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
return /^Package:[ \t]*(\S+)/m.exec(text)?.[1];
}
/**
* Scan the subtrees the main walk skipped for a package it never discovered.
*
@ -271,8 +281,8 @@ export async function hidesUndiscoveredPackage(
} else if (entry.isFile() && entry.name === 'DESCRIPTION') {
try {
const content = await fs.readFile(path.join(dir, entry.name), 'utf-8');
const pkgMatch = content.match(/^Package:\s*(\S+)/m);
if (pkgMatch && !knownPackageNames.has(pkgMatch[1])) return true;
const pkgName = descriptionPackageName(content);
if (pkgName !== undefined && !knownPackageNames.has(pkgName)) return true;
} catch {
return true;
}
@ -325,9 +335,8 @@ export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConf
try {
const descPath = path.join(dir, entry.name);
const content = await fs.readFile(descPath, 'utf-8');
const pkgMatch = content.match(/^Package:\s*(\S+)/m);
if (pkgMatch) {
const pkgName = pkgMatch[1];
const pkgName = descriptionPackageName(content);
if (pkgName !== undefined) {
const pkgDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
packageDirs.add(pkgDir);
// Same name in several directories: keep the preferred one, whatever the

View file

@ -0,0 +1,47 @@
/**
* The `Package:` field of a `DESCRIPTION` file names a package even when the file starts with a
* UTF-8 byte-order mark, and a field with an empty value names nothing rather than capturing the
* name of the next line's field. Both discovery paths read the field the same way: the package
* walk (`loadRPackageConfig`) and the hidden-package scan (`hidesUndiscoveredPackage`).
*/
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
hidesUndiscoveredPackage,
loadRPackageConfig,
} from '../../src/core/ingestion/languages/r/package-config.js';
import { createTempDirPool } from '../helpers/temp-dir-pool.js';
const pool = createTempDirPool('gn-r-desc-');
const repoWith = (description: string): string => {
const root = pool.dir();
fs.writeFileSync(path.join(root, 'DESCRIPTION'), description);
return root;
};
describe('DESCRIPTION Package: field', () => {
it('discovers a package whose DESCRIPTION starts with a UTF-8 byte-order mark', async () => {
const config = await loadRPackageConfig(repoWith('Package: bompkg\nVersion: 1.0\n'));
expect([...(config?.packages ?? [])]).toEqual([['bompkg', '']]);
});
it('sees a byte-order-marked package when scanning for a hidden one', async () => {
const root = repoWith('Package: bompkg\nVersion: 1.0\n');
expect(await hidesUndiscoveredPackage([root], new Set())).toBe(true);
expect(await hidesUndiscoveredPackage([root], new Set(['bompkg']))).toBe(false);
});
it('does not take the next line as the value of an empty Package: field', async () => {
const config = await loadRPackageConfig(repoWith('Package:\nVersion: 1.0\n'));
expect([...(config?.packages.keys() ?? [])]).toEqual([]);
const root = repoWith('Package:\nVersion: 1.0\n');
expect(await hidesUndiscoveredPackage([root], new Set())).toBe(false);
});
it('still reads an ordinary field, with CRLF line endings and extra blanks', async () => {
const config = await loadRPackageConfig(repoWith('Package: spaced\r\nVersion: 1.0\r\n'));
expect([...(config?.packages.keys() ?? [])]).toEqual(['spaced']);
});
});