diff --git a/src/m365/spfx/commands/project/project-upgrade.spec.ts b/src/m365/spfx/commands/project/project-upgrade.spec.ts index 2ea67473c3a..b51714038f2 100644 --- a/src/m365/spfx/commands/project/project-upgrade.spec.ts +++ b/src/m365/spfx/commands/project/project-upgrade.spec.ts @@ -1552,7 +1552,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.5.0', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 26); + assert.strictEqual(findings.length, 25); }); it('e2e: shows correct number of findings for upgrading react web part 1.4.1 project to 1.5.0', async () => { @@ -1560,7 +1560,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.5.0', debug: true, output: 'json' } } as any); const findings: Finding[] = log[3]; - assert.strictEqual(findings.length, 26); + assert.strictEqual(findings.length, 25); }); it('e2e: shows correct number of findings for upgrading web part with optional dependencies 1.4.1 project to 1.5.0', async () => { @@ -1568,7 +1568,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.5.0', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 33); + assert.strictEqual(findings.length, 32); }); it('e2e: shows correct number of findings for upgrading application customizer 1.4.1 project to 1.5.0', async () => { @@ -1576,7 +1576,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.5.0', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 26); + assert.strictEqual(findings.length, 25); }); it('e2e: shows correct number of findings for upgrading list view command set 1.4.1 project to 1.5.0', async () => { @@ -1584,7 +1584,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.5.0', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 26); + assert.strictEqual(findings.length, 25); }); it('e2e: shows correct number of findings for upgrading field customizer react 1.4.1 project to 1.5.0', async () => { @@ -1592,7 +1592,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.5.0', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 25); + assert.strictEqual(findings.length, 24); }); //#endregion @@ -2934,7 +2934,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.17.3', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 10); + assert.strictEqual(findings.length, 9); }); it('e2e: shows correct number of findings for upgrading form customizer react 1.17.2 project to 1.17.3', async () => { @@ -2942,7 +2942,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.17.3', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 12); + assert.strictEqual(findings.length, 11); }); it('e2e: shows correct number of findings for upgrading list view command set 1.17.2 project to 1.17.3', async () => { @@ -2966,7 +2966,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.17.3', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 14); + assert.strictEqual(findings.length, 13); }); it('e2e: shows correct number of findings for upgrading web part with optional dependencies 1.17.2 project to 1.17.3', async () => { @@ -3602,7 +3602,7 @@ describe(commands.PROJECT_UPGRADE, () => { await command.action(logger, { options: { toVersion: '1.6.0', output: 'json' } } as any); const findings: FindingToReport[] = log[0]; - assert.strictEqual(findings.length, 32); + assert.strictEqual(findings.length, 31); }); //#endregion diff --git a/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.spec.ts b/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.spec.ts index 78b2223ee37..d5e7b515167 100644 --- a/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.spec.ts +++ b/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.spec.ts @@ -186,6 +186,87 @@ describe('DependencyRule', () => { assert.strictEqual(findings.length, 0); }); + it('returns notification when rule version is higher than open-ended range', () => { + const project: Project = { + path: '/usr/tmp', + packageJson: { + dependencies: { + 'test-package': '>=0.5.0 <0.9.0' + }, + devDependencies: {}, + source: JSON.stringify({ + dependencies: { + 'test-package': '>=0.5.0 <0.9.0' + }, + devDependencies: {} + }, null, 2) + } + }; + depRule.visit(project, findings); + assert.strictEqual(findings.length, 1, 'Incorrect number of findings'); + assert.strictEqual(findings[0].occurrences[0].position?.line, 3, 'Incorrect line number'); + }); + + it('handles version range with multiple upper bounds correctly', () => { + const project: Project = { + path: '/usr/tmp', + packageJson: { + dependencies: { + 'test-package': '>=0.2.0 <=0.3.0 || >=0.4.0 <=0.9.0' + }, + devDependencies: {}, + source: JSON.stringify({ + dependencies: { + 'test-package': '>=0.2.0 <=0.3.0 || >=0.4.0 <=0.9.0' + }, + devDependencies: {} + }, null, 2) + } + }; + depRule.visit(project, findings); + assert.strictEqual(findings.length, 1, 'Incorrect number of findings'); + }); + + it('handles version range without upper bound correctly', () => { + const project: Project = { + path: '/usr/tmp', + packageJson: { + dependencies: { + 'test-package': '>=1.5.0' + }, + devDependencies: {}, + source: JSON.stringify({ + dependencies: { + 'test-package': '>=1.5.0' + }, + devDependencies: {} + }, null, 2) + } + }; + depRule.visit(project, findings); + assert.strictEqual(findings.length, 0, 'Incorrect number of findings'); + }); + + it('handles version range with only upper bounds correctly', () => { + const project: Project = { + path: '/usr/tmp', + packageJson: { + dependencies: { + 'test-package': '<=0.9.0' + }, + devDependencies: {}, + source: JSON.stringify({ + dependencies: { + 'test-package': '<=0.9.0' + }, + devDependencies: {} + }, null, 2) + } + }; + depRule.visit(project, findings); + assert.strictEqual(findings.length, 1, 'Incorrect number of findings'); + }); + it('returns uninstall resolution for uninstall a dev dependency', () => { const rule: DependencyRule = new DevDepRule2(); assert.strictEqual(rule.resolution, 'uninstallDev test-package'); diff --git a/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.ts b/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.ts index 58c343580c3..42a942f318c 100644 --- a/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.ts +++ b/src/m365/spfx/commands/project/project-upgrade/rules/DependencyRule.ts @@ -1,4 +1,4 @@ -import { lt, valid, validRange } from 'semver'; +import semver from 'semver'; import { Hash } from '../../../../../../utils/types.js'; import { JsonRule } from '../../JsonRule.js'; import { Project } from '../../project-model/index.js'; @@ -47,25 +47,15 @@ export abstract class DependencyRule extends JsonRule { const projectDependencies: Hash | undefined = this.isDevDep ? project.packageJson.devDependencies : project.packageJson.dependencies; const versionEntry: string | null = projectDependencies ? projectDependencies[this.packageName] : ''; - const packageVersion: string | null = valid(versionEntry); - const versionRange: string | null = validRange(versionEntry); if (this.add) { let jsonProperty: string = this.isDevDep ? 'devDependencies' : 'dependencies'; if (versionEntry) { jsonProperty += `.${this.packageName}`; - if (packageVersion) { - if (lt(packageVersion, this.packageVersion)) { - const node = this.getAstNodeFromFile(project.packageJson, jsonProperty); - this.addFindingWithPosition(findings, node); - } - } - else { - if (versionRange) { - const node = this.getAstNodeFromFile(project.packageJson, jsonProperty); - this.addFindingWithPosition(findings, node); - } + if (this.#needsUpdate(this.packageVersion, versionEntry)) { + const node = this.getAstNodeFromFile(project.packageJson, jsonProperty); + this.addFindingWithPosition(findings, node); } } else { @@ -88,4 +78,71 @@ export abstract class DependencyRule extends JsonRule { } } } + + /** + * Determines if a package needs to be updated based on a rule version + * @param {string} ruleVersion - The version/range from the rule (e.g., '5.8.1', '~5.8.0', '^6.0.0') + * @param {string} currentVersion - The version/range from package.json + * @returns {boolean} - true if update is needed + */ + #needsUpdate(ruleVersion: string, currentVersion: string): boolean { + try { + // Get minimum versions for both + const ruleMin = semver.minVersion(ruleVersion); + const currentMin = semver.minVersion(currentVersion); + + // Check if ranges overlap + const rangesOverlap = semver.intersects(ruleVersion, currentVersion); + + if (rangesOverlap) { + // Even if they overlap, update if rule requires a higher minimum version + if (ruleMin && currentMin && semver.gt(ruleMin, currentMin)) { + return true; + } + return false; + } + + // Ranges don't overlap - check if rule range is greater + // Get the maximum version that satisfies the current range + const currentMax = this.#getMaxVersion(currentVersion); + + // If rule's minimum is greater than current's maximum, update is needed + return !!(ruleMin && currentMax && semver.gt(ruleMin, currentMax)); + } + catch { + return false; + } + } + + /** + * Gets the maximum version from a range + * For open-ended ranges like '>=1.0.0', returns the minVersion + * For bounded ranges, returns the upper bound + */ + #getMaxVersion(range: string): semver.SemVer | null { + const rangeObj = new semver.Range(range); + + // If it's a specific version (no range operators), return it + if (semver.valid(range)) { + return semver.parse(range); + } + + // For ranges, get the highest version from the set + // Check the range set to find upper bounds + let maxVer = null; + + for (const comparatorSet of rangeObj.set) { + for (const comparator of comparatorSet) { + if (comparator.operator === '<' || comparator.operator === '<=') { + const ver = comparator.semver; + if (!maxVer || semver.gt(ver, maxVer)) { + maxVer = ver; + } + } + } + } + + // If no upper bound found, use minVersion as fallback + return maxVer || semver.minVersion(range); + } } \ No newline at end of file