Skip to content

Commit 5ebd685

Browse files
authoredMar 30, 2021
Merge pull request #1777 from snyk/feat/fix-with-version-provenance
Feat: v1 fix with version provenance for requirements.txt projects
2 parents 17e3431 + b286418 commit 5ebd685

File tree

17 files changed

+586
-255
lines changed

17 files changed

+586
-255
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { CustomError, ERROR_CODES } from './custom-error';
2+
3+
export class MissingFileNameError extends CustomError {
4+
public constructor() {
5+
super(
6+
'Filename is missing from test result. Please contact support@snyk.io.',
7+
ERROR_CODES.MissingFileName,
8+
);
9+
}
10+
}

‎packages/snyk-fix/src/plugins/python/handlers/pip-requirements/extract-version-provenance.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { Workspace } from '../../../../types';
99
import { containsRequireDirective } from './contains-require-directive';
1010

11-
interface PythonProvenance {
11+
export interface PythonProvenance {
1212
[fileName: string]: ParsedRequirements;
1313
}
1414

@@ -20,7 +20,8 @@ export async function extractProvenance(
2020
fileName: string,
2121
provenance: PythonProvenance = {},
2222
): Promise<PythonProvenance> {
23-
const requirementsTxt = await workspace.readFile(path.join(dir, fileName));
23+
const requirementsFileName = path.join(dir, fileName);
24+
const requirementsTxt = await workspace.readFile(requirementsFileName);
2425
provenance = {
2526
...provenance,
2627
[fileName]: parseRequirementsFile(requirementsTxt),
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import * as debugLib from 'debug';
2+
import * as pathLib from 'path';
3+
const sortBy = require('lodash.sortby');
4+
const groupBy = require('lodash.groupby');
25

36
import {
47
EntityToFix,
8+
FixChangesSummary,
59
FixOptions,
6-
WithFixChangesApplied,
10+
RemediationChanges,
11+
Workspace,
712
} from '../../../../types';
813
import { PluginFixResponse } from '../../../types';
914
import { updateDependencies } from './update-dependencies';
1015
import { MissingRemediationDataError } from '../../../../lib/errors/missing-remediation-data';
1116
import { MissingFileNameError } from '../../../../lib/errors/missing-file-name';
1217
import { partitionByFixable } from './is-supported';
1318
import { NoFixesCouldBeAppliedError } from '../../../../lib/errors/no-fixes-applied';
14-
import { parseRequirementsFile } from './update-dependencies/requirements-file-parser';
19+
import { extractProvenance } from './extract-version-provenance';
20+
import {
21+
ParsedRequirements,
22+
parseRequirementsFile,
23+
} from './update-dependencies/requirements-file-parser';
1524

1625
const debug = debugLib('snyk-fix:python:requirements.txt');
1726

@@ -26,56 +35,207 @@ export async function pipRequirementsTxt(
2635
skipped: [],
2736
};
2837

29-
const { fixable, skipped } = await partitionByFixable(entities);
30-
handlerResult.skipped.push(...skipped);
38+
const { fixable, skipped: notFixable } = await partitionByFixable(entities);
39+
handlerResult.skipped.push(...notFixable);
3140

32-
for (const entity of fixable) {
33-
try {
34-
const fixedEntity = await fixIndividualRequirementsTxt(entity, options);
35-
handlerResult.succeeded.push(fixedEntity);
36-
} catch (e) {
37-
handlerResult.failed.push({ original: entity, error: e });
38-
}
41+
const ordered = sortByDirectory(fixable);
42+
const fixedFilesCache: string[] = [];
43+
for (const dir of Object.keys(ordered)) {
44+
debug(`Fixing entities in directory ${dir}`);
45+
const entitiesPerDirectory = ordered[dir].map((e) => e.entity);
46+
const { failed, succeeded, skipped, fixedFiles } = await fixAll(
47+
entitiesPerDirectory,
48+
options,
49+
fixedFilesCache,
50+
);
51+
fixedFilesCache.push(...fixedFiles);
52+
handlerResult.succeeded.push(...succeeded);
53+
handlerResult.failed.push(...failed);
54+
handlerResult.skipped.push(...skipped);
3955
}
4056
return handlerResult;
4157
}
4258

43-
// TODO: optionally verify the deps install
44-
export async function fixIndividualRequirementsTxt(
59+
export function getRequiredData(
4560
entity: EntityToFix,
46-
options: FixOptions,
47-
): Promise<WithFixChangesApplied<EntityToFix>> {
48-
const fileName = entity.scanResult.identity.targetFile;
49-
const remediationData = entity.testResult.remediation;
50-
if (!remediationData) {
61+
): {
62+
remediation: RemediationChanges;
63+
targetFile: string;
64+
workspace: Workspace;
65+
} {
66+
const { remediation } = entity.testResult;
67+
if (!remediation) {
5168
throw new MissingRemediationDataError();
5269
}
53-
if (!fileName) {
70+
const { targetFile } = entity.scanResult.identity;
71+
if (!targetFile) {
5472
throw new MissingFileNameError();
5573
}
56-
const requirementsTxt = await entity.workspace.readFile(fileName);
57-
const requirementsData = parseRequirementsFile(requirementsTxt);
74+
const { workspace } = entity;
75+
if (!workspace) {
76+
throw new NoFixesCouldBeAppliedError();
77+
}
78+
return { targetFile, remediation, workspace };
79+
}
5880

59-
// TODO: allow handlers per fix type (later also strategies or combine with strategies)
60-
const { updatedManifest, changes } = updateDependencies(
61-
requirementsData,
62-
remediationData.pin,
81+
async function fixAll(
82+
entities: EntityToFix[],
83+
options: FixOptions,
84+
fixedCache: string[],
85+
): Promise<PluginFixResponse & { fixedFiles: string[] }> {
86+
const handlerResult: PluginFixResponse = {
87+
succeeded: [],
88+
failed: [],
89+
skipped: [],
90+
};
91+
for (const entity of entities) {
92+
const targetFile = entity.scanResult.identity.targetFile!;
93+
try {
94+
const { dir, base } = pathLib.parse(targetFile);
95+
// parse & join again to support correct separator
96+
if (fixedCache.includes(pathLib.join(dir, base))) {
97+
handlerResult.succeeded.push({
98+
original: entity,
99+
changes: [{ success: true, userMessage: 'Previously fixed' }],
100+
});
101+
continue;
102+
}
103+
const { changes, fixedFiles } = await applyAllFixes(entity, options);
104+
if (!changes.length) {
105+
debug('Manifest has not changed!');
106+
throw new NoFixesCouldBeAppliedError();
107+
}
108+
fixedCache.push(...fixedFiles);
109+
handlerResult.succeeded.push({ original: entity, changes });
110+
} catch (e) {
111+
debug(`Failed to fix ${targetFile}.\nERROR: ${e}`);
112+
handlerResult.failed.push({ original: entity, error: e });
113+
}
114+
}
115+
return { ...handlerResult, fixedFiles: [] };
116+
}
117+
// TODO: optionally verify the deps install
118+
export async function fixIndividualRequirementsTxt(
119+
workspace: Workspace,
120+
dir: string,
121+
entryFileName: string,
122+
fileName: string,
123+
remediation: RemediationChanges,
124+
parsedRequirements: ParsedRequirements,
125+
options: FixOptions,
126+
directUpgradesOnly: boolean,
127+
): Promise<{ changes: FixChangesSummary[]; appliedRemediation: string[] }> {
128+
const fullFilePath = pathLib.join(dir, fileName);
129+
const { updatedManifest, changes, appliedRemediation } = updateDependencies(
130+
parsedRequirements,
131+
remediation.pin,
132+
directUpgradesOnly,
133+
pathLib.join(dir, entryFileName) !== fullFilePath ? fileName : undefined,
63134
);
64135

65-
// TODO: do this with the changes now that we only return new
66-
if (updatedManifest === requirementsTxt) {
67-
debug('Manifest has not changed!');
68-
throw new NoFixesCouldBeAppliedError();
136+
if (!changes.length) {
137+
return { changes, appliedRemediation };
69138
}
139+
70140
if (!options.dryRun) {
71141
debug('Writing changes to file');
72-
await entity.workspace.writeFile(fileName, updatedManifest);
142+
await workspace.writeFile(pathLib.join(dir, fileName), updatedManifest);
73143
} else {
74144
debug('Skipping writing changes to file in --dry-run mode');
75145
}
76146

77-
return {
78-
original: entity,
79-
changes,
147+
return { changes, appliedRemediation };
148+
}
149+
150+
export async function applyAllFixes(
151+
entity: EntityToFix,
152+
options: FixOptions,
153+
): Promise<{ changes: FixChangesSummary[]; fixedFiles: string[] }> {
154+
const { remediation, targetFile: entryFileName, workspace } = getRequiredData(
155+
entity,
156+
);
157+
const fixedFiles: string[] = [];
158+
const { dir, base } = pathLib.parse(entryFileName);
159+
const provenance = await extractProvenance(workspace, dir, base);
160+
const upgradeChanges: FixChangesSummary[] = [];
161+
const appliedUpgradeRemediation: string[] = [];
162+
/* Apply all upgrades first across all files that are included */
163+
for (const fileName of Object.keys(provenance)) {
164+
const skipApplyingPins = true;
165+
const { changes, appliedRemediation } = await fixIndividualRequirementsTxt(
166+
workspace,
167+
dir,
168+
base,
169+
fileName,
170+
remediation,
171+
provenance[fileName],
172+
options,
173+
skipApplyingPins,
174+
);
175+
appliedUpgradeRemediation.push(...appliedRemediation);
176+
upgradeChanges.push(...changes);
177+
fixedFiles.push(pathLib.join(dir, fileName));
178+
}
179+
180+
/* Apply all left over remediation as pins in the entry targetFile */
181+
const requirementsTxt = await workspace.readFile(entryFileName);
182+
183+
const toPin: RemediationChanges = filterOutAppliedUpgrades(
184+
remediation,
185+
appliedUpgradeRemediation,
186+
);
187+
const directUpgradesOnly = false;
188+
const { changes: pinnedChanges } = await fixIndividualRequirementsTxt(
189+
workspace,
190+
dir,
191+
base,
192+
base,
193+
toPin,
194+
parseRequirementsFile(requirementsTxt),
195+
options,
196+
directUpgradesOnly,
197+
);
198+
199+
return { changes: [...upgradeChanges, ...pinnedChanges], fixedFiles };
200+
}
201+
202+
function filterOutAppliedUpgrades(
203+
remediation: RemediationChanges,
204+
appliedRemediation: string[],
205+
): RemediationChanges {
206+
const pinRemediation: RemediationChanges = {
207+
...remediation,
208+
pin: {}, // delete the pin remediation so we can collect un-applied remediation
80209
};
210+
const pins = remediation.pin;
211+
const lowerCasedAppliedRemediation = appliedRemediation.map((i) =>
212+
i.toLowerCase(),
213+
);
214+
for (const pkgAtVersion of Object.keys(pins)) {
215+
if (!lowerCasedAppliedRemediation.includes(pkgAtVersion.toLowerCase())) {
216+
pinRemediation.pin[pkgAtVersion] = pins[pkgAtVersion];
217+
}
218+
}
219+
return pinRemediation;
220+
}
221+
222+
function sortByDirectory(
223+
entities: EntityToFix[],
224+
): {
225+
[dir: string]: Array<{
226+
entity: EntityToFix;
227+
dir: string;
228+
base: string;
229+
ext: string;
230+
root: string;
231+
name: string;
232+
}>;
233+
} {
234+
const mapped = entities.map((e) => ({
235+
entity: e,
236+
...pathLib.parse(e.scanResult.identity.targetFile!),
237+
}));
238+
239+
const sorted = sortBy(mapped, 'dir');
240+
return groupBy(sorted, 'dir');
81241
}

‎packages/snyk-fix/src/plugins/python/handlers/pip-requirements/is-supported.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ export async function isSupported(
4545
};
4646
}
4747

48-
const { containsRequire } = await containsRequireDirective(requirementsTxt);
49-
if (containsRequire) {
48+
const { containsRequire, matches } = await containsRequireDirective(
49+
requirementsTxt,
50+
);
51+
if (containsRequire && matches.some((m) => m.includes('c'))) {
5052
return {
5153
supported: false,
52-
reason: `Requirements with ${chalk.bold('-r')} or ${chalk.bold(
54+
reason: `Requirements with ${chalk.bold(
5355
'-c',
5456
)} directive are not yet supported`,
5557
};

‎packages/snyk-fix/src/plugins/python/handlers/pip-requirements/update-dependencies/generate-pins.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { Requirement } from './requirements-file-parser';
66
export function generatePins(
77
requirements: Requirement[],
88
updates: DependencyPins,
9-
): { pinnedRequirements: string[]; changes: FixChangesSummary[] } {
9+
): {
10+
pinnedRequirements: string[];
11+
changes: FixChangesSummary[];
12+
appliedRemediation: string[];
13+
} {
1014
// Lowercase the upgrades object. This might be overly defensive, given that
1115
// we control this input internally, but its a low cost guard rail. Outputs a
1216
// mapping of upgrade to -> from, instead of the nested upgradeTo object.
@@ -20,8 +24,10 @@ export function generatePins(
2024
return {
2125
pinnedRequirements: [],
2226
changes: [],
27+
appliedRemediation: [],
2328
};
2429
}
30+
const appliedRemediation: string[] = [];
2531
const changes: FixChangesSummary[] = [];
2632
const pinnedRequirements = Object.keys(lowerCasedPins)
2733
.map((pkgNameAtVersion) => {
@@ -32,12 +38,14 @@ export function generatePins(
3238
success: true,
3339
userMessage: `Pinned ${pkgName} from ${version} to ${newVersion}`,
3440
});
41+
appliedRemediation.push(pkgNameAtVersion);
3542
return `${newRequirement} # not directly required, pinned by Snyk to avoid a vulnerability`;
3643
})
3744
.filter(isDefined);
3845

3946
return {
4047
pinnedRequirements,
4148
changes,
49+
appliedRemediation,
4250
};
4351
}

‎packages/snyk-fix/src/plugins/python/handlers/pip-requirements/update-dependencies/generate-upgrades.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { UpgradedRequirements } from './types';
66
export function generateUpgrades(
77
requirements: Requirement[],
88
updates: DependencyPins,
9-
): { updatedRequirements: UpgradedRequirements; changes: FixChangesSummary[] } {
9+
referenceFileInChanges?: string,
10+
): {
11+
updatedRequirements: UpgradedRequirements;
12+
changes: FixChangesSummary[];
13+
appliedRemediation: string[];
14+
} {
1015
// Lowercase the upgrades object. This might be overly defensive, given that
1116
// we control this input internally, but its a low cost guard rail. Outputs a
1217
// mapping of upgrade to -> from, instead of the nested upgradeTo object.
@@ -19,9 +24,11 @@ export function generateUpgrades(
1924
return {
2025
updatedRequirements: {},
2126
changes: [],
27+
appliedRemediation: [],
2228
};
2329
}
2430

31+
const appliedRemediation: string[] = [];
2532
const changes: FixChangesSummary[] = [];
2633
const updatedRequirements = {};
2734
requirements.map(
@@ -58,16 +65,22 @@ export function generateUpgrades(
5865
const updatedRequirement = `${originalName}${versionComparator}${newVersion}`;
5966
changes.push({
6067
success: true,
61-
userMessage: `Upgraded ${originalName} from ${version} to ${newVersion}`,
68+
userMessage: `Upgraded ${originalName} from ${version} to ${newVersion}${
69+
referenceFileInChanges
70+
? ` (upgraded in ${referenceFileInChanges})`
71+
: ''
72+
}`,
6273
});
6374
updatedRequirements[originalText] = `${updatedRequirement}${
6475
extras ? extras : ''
6576
}`;
77+
appliedRemediation.push(upgrade);
6678
},
6779
);
6880

6981
return {
7082
updatedRequirements,
7183
changes,
84+
appliedRemediation,
7285
};
7386
}

‎packages/snyk-fix/src/plugins/python/handlers/pip-requirements/update-dependencies/index.ts

+24-10
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ const debug = debugLib('snyk-fix:python:update-dependencies');
1919
export function updateDependencies(
2020
parsedRequirementsData: ParsedRequirements,
2121
updates: DependencyPins,
22-
): { updatedManifest: string; changes: FixChangesSummary[] } {
22+
directUpgradesOnly = false,
23+
referenceFileInChanges?: string,
24+
): {
25+
updatedManifest: string;
26+
changes: FixChangesSummary[];
27+
appliedRemediation: string[];
28+
} {
2329
const {
2430
requirements,
2531
endsWithNewLine: shouldEndWithNewLine,
@@ -32,17 +38,24 @@ export function updateDependencies(
3238
}
3339
debug('Finished parsing manifest');
3440

35-
const { updatedRequirements, changes: upgradedChanges } = generateUpgrades(
36-
requirements,
37-
updates,
38-
);
41+
const {
42+
updatedRequirements,
43+
changes: upgradedChanges,
44+
appliedRemediation,
45+
} = generateUpgrades(requirements, updates, referenceFileInChanges);
3946
debug('Finished generating upgrades to apply');
4047

41-
const { pinnedRequirements, changes: pinChanges } = generatePins(
42-
requirements,
43-
updates,
44-
);
45-
debug('Finished generating pins to apply');
48+
let pinnedRequirements: string[] = [];
49+
let pinChanges: FixChangesSummary[] = [];
50+
let appliedPinsRemediation: string[] = [];
51+
if (!directUpgradesOnly) {
52+
({
53+
pinnedRequirements,
54+
changes: pinChanges,
55+
appliedRemediation: appliedPinsRemediation,
56+
} = generatePins(requirements, updates));
57+
debug('Finished generating pins to apply');
58+
}
4659

4760
let updatedManifest = [
4861
...applyUpgrades(requirements, updatedRequirements),
@@ -59,5 +72,6 @@ export function updateDependencies(
5972
return {
6073
updatedManifest,
6174
changes: [...pinChanges, ...upgradedChanges],
75+
appliedRemediation: [...appliedPinsRemediation, ...appliedRemediation],
6276
};
6377
}

‎packages/snyk-fix/test/acceptance/plugins/python/update-dependencies/__snapshots__/update-dependencies.spec.ts.snap

+50
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,55 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`fix *req*.txt / *.txt Python projects fixes multiple files that are included via -r 1`] = `
4+
Array [
5+
Object {
6+
"success": true,
7+
"userMessage": "Upgraded Django from 1.6.1 to 2.0.1",
8+
},
9+
Object {
10+
"success": true,
11+
"userMessage": "Upgraded Jinja2 from 2.7.2 to 2.7.3 (upgraded in base2.txt)",
12+
},
13+
]
14+
`;
15+
16+
exports[`fix *req*.txt / *.txt Python projects fixes multiple files via -r with the same name (some were already fixed) 1`] = `
17+
"Successful fixes:
18+
19+
app-with-already-fixed/requirements.txt
20+
✔ Upgraded Django from 1.6.1 to 2.0.1
21+
✔ Upgraded Django from 1.6.1 to 2.0.1 (upgraded in core/requirements.txt)
22+
✔ Upgraded Jinja2 from 2.7.2 to 2.7.3 (upgraded in lib/requirements.txt)
23+
24+
app-with-already-fixed/core/requirements.txt
25+
✔ Previously fixed
26+
27+
app-with-already-fixed/lib/requirements.txt
28+
✔ Previously fixed
29+
30+
Summary:
31+
32+
0 items were not fixed
33+
3 items were successfully fixed"
34+
`;
35+
36+
exports[`fix *req*.txt / *.txt Python projects fixes multiple files via -r with the same name (some were already fixed) 2`] = `
37+
Array [
38+
Object {
39+
"success": true,
40+
"userMessage": "Upgraded Django from 1.6.1 to 2.0.1",
41+
},
42+
Object {
43+
"success": true,
44+
"userMessage": "Upgraded Django from 1.6.1 to 2.0.1 (upgraded in core/requirements.txt)",
45+
},
46+
Object {
47+
"success": true,
48+
"userMessage": "Upgraded Jinja2 from 2.7.2 to 2.7.3 (upgraded in lib/requirements.txt)",
49+
},
50+
]
51+
`;
52+
353
exports[`fix *req*.txt / *.txt Python projects retains python markers 1`] = `
454
"amqp==2.4.2
555
apscheduler==3.6.0

‎packages/snyk-fix/test/acceptance/plugins/python/update-dependencies/update-dependencies.spec.ts

+230-201
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
click>7.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Django==1.6.1 ; python_version > '1.0'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Jinja2==2.7.2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-r core/requirements.txt
2+
-r lib/requirements.txt
3+
-r base.txt
4+
Django==1.6.1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
-r base.txt
2+
-r base2.txt
23
Django==1.6.1

‎packages/snyk-fix/test/unit/fix.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('Snyk fix', () => {
1717
});
1818

1919
// Assert
20-
expect(writeFileSpy).toHaveBeenCalled();
20+
expect(writeFileSpy).toHaveBeenCalledTimes(1);
2121
expect(res.exceptions).toMatchSnapshot();
2222
expect(res.results).toMatchSnapshot();
2323
});

‎packages/snyk-fix/test/unit/plugins/python/handlers/update-dependencies/update-dependencies.spec.ts

+36
Original file line numberDiff line numberDiff line change
@@ -306,4 +306,40 @@ describe('remediation', () => {
306306
);
307307
}
308308
});
309+
it('skips pins if asked', () => {
310+
const upgrades = {
311+
'django@1.6.1': {
312+
upgradeTo: 'django@2.0.1',
313+
vulns: [],
314+
upgrades: [],
315+
isTransitive: false,
316+
},
317+
'transitive@1.0.0': {
318+
upgradeTo: 'transitive@1.1.1',
319+
vulns: [],
320+
upgrades: [],
321+
isTransitive: true,
322+
},
323+
};
324+
325+
const manifestContents = 'Django==1.6.1';
326+
327+
const expectedManifest =
328+
'Django==2.0.1\ntransitive>=1.1.1 # not directly required, pinned by Snyk to avoid a vulnerability';
329+
const directUpgradesOnly = false;
330+
const requirements = parseRequirementsFile(manifestContents);
331+
const result = updateDependencies(
332+
requirements,
333+
upgrades,
334+
directUpgradesOnly,
335+
);
336+
expect(result.changes.map((c) => c.userMessage).sort()).toEqual(
337+
[
338+
'Pinned transitive from 1.0.0 to 1.1.1',
339+
'Upgraded Django from 1.6.1 to 2.0.1',
340+
].sort(),
341+
);
342+
// Note no extra newline was added to the expected manifest
343+
expect(result.updatedManifest).toEqual(expectedManifest);
344+
});
309345
});

‎packages/snyk-fix/test/unit/plugins/python/is-supported.ts ‎packages/snyk-fix/test/unit/plugins/python/is-supported.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ describe('isSupported', () => {
1313
const res = await isSupported(entity);
1414
expect(res.supported).toBeFalsy();
1515
});
16-
it('with -r directive in the manifest not supported', async () => {
16+
it('with -r directive in the manifest is supported', async () => {
1717
const entity = generateEntityToFix(
1818
'pip',
1919
'requirements.txt',
2020
'-r prod.txt\nDjango==1.6.1',
2121
);
2222
const res = await isSupported(entity);
23-
expect(res.supported).toBeFalsy();
23+
expect(res.supported).toBeTruthy();
2424
});
2525
it('with -c directive in the manifest not supported', async () => {
2626
const entity = generateEntityToFix(

0 commit comments

Comments
 (0)
Please sign in to comment.