Skip to content

Commit df46a35

Browse files
committed
fix(scripts): read attributions only from the notices header block
The pack gate scanned every line of THIRD_PARTY_NOTICES.txt for an attribution, but the renderer emits the attributions as one block before the first numbered section and copies each section's license or NOTICE text verbatim after it. Prose there belongs to the upstream package: a contact line or a pointer to "section [9]" shaped like an attribution made the gate reject bytes the bundler legitimately wrote, and one such line let a file that attributes nobody clear the gate that exists to catch exactly that. Scope attribution parsing to the header block the producer writes; the section scan, the reference checks and the reject directions stay as they were.
1 parent 53539d3 commit df46a35

2 files changed

Lines changed: 79 additions & 4 deletions

File tree

‎scripts/__tests__/size-report-package.test.ts‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,9 @@ function dependencyManifest(name: string, fields: Record<string, string>): [stri
249249
* are the ones `renderNotices` writes — grouped SPDX field, author clause and all — rather than a
250250
* hand-written approximation of them.
251251
*/
252-
async function renderedNotices(dependencies: [string, Record<string, string>][]): Promise<string> {
252+
async function renderedNotices(
253+
dependencies: [string, Record<string, string>, Record<string, string>?][],
254+
): Promise<string> {
253255
const root = await realpath(await mkdtempForTest('pack-gate-render-'));
254256
const files: [string, string][] = [
255257
['package.json', JSON.stringify({ name: 'fixture-project', type: 'module' })],
@@ -262,7 +264,10 @@ async function renderedNotices(dependencies: [string, Record<string, string>][])
262264
],
263265
['input.ts', dependencies.map(([name]) => `export { ${name} } from '${name}';`).join('\n')],
264266
];
265-
for (const [name, fields] of dependencies) files.push(...dependencyManifest(name, fields));
267+
for (const [name, fields, extras] of dependencies) {
268+
files.push(...dependencyManifest(name, fields));
269+
if (extras) files.push(...Object.entries(extras));
270+
}
266271
for (const [relative, source] of files) {
267272
const file = join(root, relative);
268273
await mkdir(join(file, '..'), { recursive: true });
@@ -313,6 +318,56 @@ test('pack gate reads the license reference, not the author clause, from real by
313318
);
314319
});
315320

321+
test('pack gate reads no attribution from license or NOTICE body text', async () => {
322+
// A numbered section holds upstream license or NOTICE text verbatim, at column zero: the renderer
323+
// neither escapes nor indents it. Prose shaped like an attribution — an email contact, a line
324+
// pointing at "section [9]" of some other package — is ordinary upstream content, and the only
325+
// attributions the bundler itself writes are the block before the first heading. Reading a body
326+
// line as an attribution would reject publishable bytes: the dangling [9] and the empty reference
327+
// below are unparseable as anything but unresolved attributions.
328+
const root = await renderedNotices([
329+
[
330+
'included',
331+
{ license: 'MIT' },
332+
{
333+
'node_modules/included/NOTICE': [
334+
'This distribution bundles software from several upstreams:',
335+
'[email protected] (MIT): see section [9] for those terms',
336+
'[email protected] (Apache-2.0): ',
337+
].join('\n'),
338+
},
339+
],
340+
]);
341+
const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8');
342+
assert.match(rendered, /^helper-pkg@0\.1\.0 \(MIT\): see section \[9\]/m);
343+
assert.doesNotThrow(() =>
344+
assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }),
345+
);
346+
});
347+
348+
test('pack gate rejects a file that attributes nobody but whose license text names somebody', async () => {
349+
// The gate exists to catch a notices file that attributes nobody. A build that lost the leading
350+
// attribution block keeps the heading and bodies, and one body can happen to carry a line shaped
351+
// like an attribution pointing at its own section. That file must not clear the gate on prose it
352+
// preserves verbatim: only the block the renderer writes before the first heading attributes.
353+
const bodyOnly = await treeWithNotices(
354+
[
355+
'[1]',
356+
'Copyright (c) 2023 Callstack',
357+
'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.',
358+
'@agent-device/[email protected] (MIT): [1]',
359+
].join('\n'),
360+
);
361+
assert.throws(
362+
() =>
363+
assertBundledLicenseNotices([noticesEntry()], {
364+
noticesRoot: bodyOnly,
365+
context: 'npm pack',
366+
}),
367+
/no bundled dependency notices[\s\S]*found 0 attributions and 1 license texts/,
368+
);
369+
});
370+
316371
test('pack gate still rejects a grouped attribution whose reference is missing or absent', async () => {
317372
// The mixed file is the point: a simple attribution must not stand in for the grouped one, so the
318373
// grouped line has to be inspected and named rather than skipped as unparseable.

‎scripts/size-report-package.mjs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export function collectNpmPack(root) {
7272
*/
7373
const BUNDLED_LICENSE_NOTICES_PATH = 'dist/src/THIRD_PARTY_NOTICES.txt';
7474

75+
/**
76+
* The heading that opens a numbered license section. The renderer writes every attribution it
77+
* produces before the first one, so this line is also the boundary between the attributions the
78+
* gate reads and the license text it preserves.
79+
*/
80+
const LICENSE_SECTION_HEADING = /^\[(\d+)\]$/;
81+
7582
/**
7683
* The ids of the numbered license sections that hold text. A section runs from its `[n]` heading to
7784
* the next heading, so a license whose body carries blank lines keeps that body. A line shaped like a
@@ -82,7 +89,7 @@ function readLicenseTextIds(lines) {
8289
let openId;
8390
let seenText = false;
8491
for (const line of lines) {
85-
const heading = line.match(/^\[(\d+)\]$/);
92+
const heading = line.match(LICENSE_SECTION_HEADING);
8693
if (heading) {
8794
if (seenText) idsWithText.add(openId);
8895
openId = Number(heading[1]);
@@ -95,6 +102,19 @@ function readLicenseTextIds(lines) {
95102
return idsWithText;
96103
}
97104

105+
/**
106+
* The lines the attributions can live on: everything before the first numbered section, which is the
107+
* block the renderer writes first. A section holds a dependency's license or NOTICE text verbatim —
108+
* the renderer neither escapes nor indents it — so its content is upstream's, and prose there can
109+
* carry a contact address or a pointer to "section [9]" shaped like an attribution line. Reading
110+
* those as attributions would reject publishable bytes, and one such line would stand in for the
111+
* real attributions a build failed to write.
112+
*/
113+
function readAttributionLines(lines) {
114+
const firstSection = lines.findIndex((line) => LICENSE_SECTION_HEADING.test(line));
115+
return firstSection === -1 ? lines : lines.slice(0, firstSection);
116+
}
117+
98118
/**
99119
* The `name@version (license): [n]` line the bundler writes, or undefined for any other line. The
100120
* license field is whatever the plugin reports, wrapped in the renderer's own parentheses, so a
@@ -138,7 +158,7 @@ function describeUnresolvedAttribution(attribution) {
138158
function assertLicenseNoticeContents(contents, context) {
139159
const lines = contents.replaceAll('\r\n', '\n').split('\n');
140160
const licenseTextIds = readLicenseTextIds(lines);
141-
const attributions = lines
161+
const attributions = readAttributionLines(lines)
142162
.map(readAttribution)
143163
.filter((attribution) => attribution !== undefined);
144164
const unresolved = attributions.filter(

0 commit comments

Comments
 (0)