fix(r): flag an unreadable DESCRIPTION as truncated and tighten three R test assertions

An unreadable DESCRIPTION left RPackageConfig.truncated false, so a
qualifier naming the package it declares was classed external and its
call site dropped. The sibling hidesUndiscoveredPackage case already
treats an unreadable DESCRIPTION as unknown; do the same here and cover
it with a test.

Tighten three weak test assertions: the escaped-bracket export pattern
now asserts its exact match results, the S4 anchors test proves each
generic is defined exactly once, and the worker owner-absence test
fails on any defined ownerId, including an empty string.
This commit is contained in:
Mick Cooney 2026-10-07 15:20:38 +01:00
parent 3b478e4c46
commit 37efc888fc
5 changed files with 36 additions and 5 deletions

View file

@ -383,7 +383,8 @@ export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConf
}
}
} catch {
// Can't read DESCRIPTION
// Can't read DESCRIPTION: the package it declares was not discovered.
truncated = true;
}
}
}

View file

@ -74,7 +74,7 @@ describe.skipIf(!hasDistWorker)('R worker never emits an owner id (before post-p
let hintedMembers = 0;
graph.forEachNode((n) => {
if (n.properties.language !== 'r') return;
if (typeof n.properties.ownerId === 'string') ownedNodes++;
if (n.properties.ownerId !== undefined) ownedNodes++;
if (
(n.label === 'Method' || n.label === 'Property') &&
typeof n.properties.ownerNameHint === 'string'
@ -88,7 +88,7 @@ describe.skipIf(!hasDistWorker)('R worker never emits an owner id (before post-p
for (const result of raw) {
for (const symbol of result.symbols) {
symbols++;
if (symbol.ownerId) ownedSymbols++;
if (symbol.ownerId !== undefined) ownedSymbols++;
}
}

View file

@ -58,8 +58,8 @@ describe('R S4/RefClass definitions are named by their first argument only', ()
it('defines the generics once, with no Function from valueClass = "numeric"', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('describe');
expect(functions).toContain('summarise');
expect(functions.filter((n) => n === 'describe')).toHaveLength(1);
expect(functions.filter((n) => n === 'summarise')).toHaveLength(1);
expect(functions).not.toContain('numeric');
});

View file

@ -73,6 +73,12 @@ describe('compileRExportPattern', () => {
});
it('does not treat an escaped bracket as a bracket expression', () => {
// `\[` is a literal bracket; the rest is an ordinary one-character class of `:alph`,
// so the pattern compiles (to itself) and matches `[` plus exactly one of those characters.
expect(compileRExportPattern('^\\[[:alpha:]$')?.source).toBe('^\\[[:alpha:]$');
expect(matches('^\\[[:alpha:]$', '[a')).toBe(true);
expect(matches('^\\[[:alpha:]$', '[:')).toBe(true);
expect(matches('^\\[[:alpha:]$', '[b')).toBe(false);
expect(matches('^\\[[:alpha:]$', '[:alpha:')).toBe(false);
});

View file

@ -369,6 +369,30 @@ describe('loadRPackageConfig: truncation only when a skipped subtree hides an un
if (locked) await fs.chmod(locked, 0o755).catch(() => undefined);
}
});
it('an unreadable DESCRIPTION is truncated, so its qualifier stays unknown', async () => {
if (typeof process.getuid === 'function' && process.getuid() === 0) return; // root reads anything
let locked = '';
try {
const cfg = await withRepo(async (root) => {
locked = path.join(root, 'sub', 'DESCRIPTION');
await writePkg(path.join(root, 'sub'), 'hiddenpkg');
await fs.chmod(locked, 0o000);
// Guard: a platform that still reads a mode-000 file cannot exercise the catch.
await fs.readFile(locked).then(
() => {
throw new Error('DESCRIPTION is still readable; cannot simulate an I/O error');
},
() => undefined,
);
});
expect(cfg?.packages.has('hiddenpkg')).toBe(false);
expect(cfg?.truncated).toBe(true);
expect(rQualifierLocality('hiddenpkg', cfg as RPackageConfig, new Set())).toBe('unknown');
} finally {
if (locked) await fs.chmod(locked, 0o644).catch(() => undefined);
}
});
});
describe('hidesUndiscoveredPackage', () => {