Skip to content

Commit 49446db

Browse files
committed
Add PSUseFullyQualifiedCmdletNames rule
1 parent 168dcbd commit 49446db

5 files changed

Lines changed: 1062 additions & 0 deletions

File tree

‎Rules/Strings.resx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,27 @@
13021302
<data name="UseConsistentParameterSetNameName" xml:space="preserve">
13031303
<value>UseConsistentParameterSetName</value>
13041304
</data>
1305+
<data name="UseFullyQualifiedCmdletNamesAliasError" xml:space="preserve">
1306+
<value>The alias '{0}' should be replaced with the fully qualified cmdlet name '{1}'.</value>
1307+
</data>
1308+
<data name="UseFullyQualifiedCmdletNamesCommandError" xml:space="preserve">
1309+
<value>The cmdlet '{0}' should be replaced with the fully qualified cmdlet name '{1}'.</value>
1310+
</data>
1311+
<data name="UseFullyQualifiedCmdletNamesFunctionError" xml:space="preserve">
1312+
<value>The function '{0}' should be replaced with the fully qualified name '{1}'.</value>
1313+
</data>
1314+
<data name="UseFullyQualifiedCmdletNamesCorrection" xml:space="preserve">
1315+
<value>Replace '{0}' with '{1}'</value>
1316+
</data>
1317+
<data name="UseFullyQualifiedCmdletNamesName" xml:space="preserve">
1318+
<value>UseFullyQualifiedCmdletNames</value>
1319+
</data>
1320+
<data name="UseFullyQualifiedCmdletNamesCommonName" xml:space="preserve">
1321+
<value>Use Fully Qualified Cmdlet Names</value>
1322+
</data>
1323+
<data name="UseFullyQualifiedCmdletNamesDescription" xml:space="preserve">
1324+
<value>Commands should be called using their fully qualified names, including the module name, instead of unqualified names or aliases.</value>
1325+
</data>
13051326
<data name="AvoidReservedWordsAsFunctionNamesCommonName" xml:space="preserve">
13061327
<value>Avoid reserved words as function names</value>
13071328
</data>
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
//---------------------------------------------------------------------------------
2+
// Copyright (c) Microsoft Corporation.
3+
// The MIT License (MIT)
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
//---------------------------------------------------------------------------------
23+
24+
using System;
25+
using System.Collections.Generic;
26+
using System.Collections.Concurrent;
27+
using System.Collections.ObjectModel;
28+
using System.Linq;
29+
using System.Management.Automation;
30+
using System.Management.Automation.Language;
31+
using Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic;
32+
#if !CORECLR
33+
using System.ComponentModel.Composition;
34+
#endif
35+
using System.Globalization;
36+
37+
namespace Microsoft.Windows.PowerShell.ScriptAnalyzer.BuiltinRules
38+
{
39+
/// <summary>
40+
/// UseFullyQualifiedCmdletNames: Checks if cmdlet and function invocations use fully qualified module names.
41+
/// </summary>
42+
#if !CORECLR
43+
[Export(typeof(IScriptRule))]
44+
#endif
45+
public class UseFullyQualifiedCmdletNames : ConfigurableRule
46+
{
47+
private readonly ConcurrentDictionary<string, ResolvedCommand> resolutionCache =
48+
new ConcurrentDictionary<string, ResolvedCommand>(StringComparer.OrdinalIgnoreCase);
49+
50+
internal const string AnalyzerName = "Microsoft.Windows.PowerShell.ScriptAnalyzer";
51+
52+
/// <summary>
53+
/// Modules to ignore when applying this rule.
54+
/// Commands from these modules will not be expanded to their fully qualified names.
55+
/// Default is empty array (no modules ignored - all cmdlets are processed).
56+
/// </summary>
57+
[ConfigurableRuleProperty(defaultValue: new string[] { })]
58+
public string[] IgnoredModules { get; protected set; }
59+
60+
/// <summary>
61+
/// Analyzes the given ast to find cmdlet invocations that are not fully qualified.
62+
/// </summary>
63+
/// <param name="ast">The script's ast</param>
64+
/// <param name="fileName">The script's file name</param>
65+
/// <returns>The diagnostic results of this rule</returns>
66+
public override IEnumerable<DiagnosticRecord> AnalyzeScript(Ast ast, string fileName)
67+
{
68+
if (ast == null)
69+
{
70+
throw new ArgumentNullException(nameof(ast));
71+
}
72+
73+
var functionDefinitions = ast.FindAll(testAst => testAst is FunctionDefinitionAst, true).Cast<FunctionDefinitionAst>().ToList();
74+
75+
var commandAsts = ast.FindAll(testAst => testAst is CommandAst, true).Cast<CommandAst>();
76+
77+
foreach (var commandAst in commandAsts)
78+
{
79+
var commandName = commandAst.GetCommandName();
80+
if (string.IsNullOrWhiteSpace(commandName) || commandName.Contains("\\"))
81+
{
82+
continue;
83+
}
84+
85+
// Skip commands that resolve to a locally declared function, since qualifying them would change behavior.
86+
if (IsShadowedByLocalFunction(commandAst, commandName, functionDefinitions))
87+
{
88+
continue;
89+
}
90+
91+
var resolvedCommand = resolutionCache.GetOrAdd(commandName, ResolveCommand);
92+
if (resolvedCommand.FullyQualifiedName == null)
93+
{
94+
continue;
95+
}
96+
97+
// Re-check ignored modules for cached results (in case IgnoredModules was changed).
98+
if (IgnoredModules != null && IgnoredModules.Contains(resolvedCommand.ModuleName, StringComparer.OrdinalIgnoreCase))
99+
{
100+
continue;
101+
}
102+
103+
var extent = commandAst.CommandElements[0].Extent;
104+
105+
string message = string.Format(
106+
CultureInfo.CurrentCulture,
107+
GetErrorResource(resolvedCommand.CommandType),
108+
commandName,
109+
resolvedCommand.FullyQualifiedName);
110+
111+
string correctionDescription = string.Format(
112+
CultureInfo.CurrentCulture,
113+
Strings.UseFullyQualifiedCmdletNamesCorrection,
114+
commandName,
115+
resolvedCommand.FullyQualifiedName);
116+
117+
var suggestedCorrections = new Collection<CorrectionExtent>
118+
{
119+
new CorrectionExtent(
120+
extent.StartLineNumber,
121+
extent.EndLineNumber,
122+
extent.StartColumnNumber,
123+
extent.EndColumnNumber,
124+
resolvedCommand.FullyQualifiedName,
125+
fileName,
126+
correctionDescription)
127+
};
128+
129+
yield return new DiagnosticRecord(
130+
message,
131+
extent,
132+
GetName(),
133+
DiagnosticSeverity.Warning,
134+
fileName,
135+
null,
136+
suggestedCorrections);
137+
}
138+
}
139+
140+
/// <summary>
141+
/// Checks whether a command name matches a function declared in a scope that is visible at the
142+
/// command's location.
143+
/// </summary>
144+
private static bool IsShadowedByLocalFunction(
145+
CommandAst commandAst,
146+
string commandName,
147+
IEnumerable<FunctionDefinitionAst> functionDefinitions)
148+
{
149+
var commandScope = GetContainingScriptBlock(commandAst);
150+
151+
foreach (var functionDefinition in functionDefinitions)
152+
{
153+
if (!functionDefinition.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase))
154+
{
155+
continue;
156+
}
157+
158+
var functionScope = GetContainingScriptBlock(functionDefinition);
159+
if (functionScope != null &&
160+
(functionScope == commandScope || IsAncestorOf(functionScope, commandScope)))
161+
{
162+
return true;
163+
}
164+
}
165+
166+
return false;
167+
}
168+
169+
/// <summary>
170+
/// Returns the nearest enclosing script block, which represents the scope where a function is
171+
/// declared or a command is invoked.
172+
/// </summary>
173+
private static ScriptBlockAst GetContainingScriptBlock(Ast node)
174+
{
175+
for (Ast current = node; current != null; current = current.Parent)
176+
{
177+
if (current is ScriptBlockAst scriptBlock)
178+
{
179+
return scriptBlock;
180+
}
181+
}
182+
183+
return null;
184+
}
185+
186+
/// <summary>
187+
/// Returns true if the first AST is an ancestor of the second.
188+
/// </summary>
189+
private static bool IsAncestorOf(Ast ancestor, Ast descendant)
190+
{
191+
for (Ast current = descendant.Parent; current != null; current = current.Parent)
192+
{
193+
if (current == ancestor)
194+
{
195+
return true;
196+
}
197+
}
198+
199+
return false;
200+
}
201+
202+
/// <summary>
203+
/// Returns the message resource appropriate for the resolved command type.
204+
/// </summary>
205+
private static string GetErrorResource(CommandTypes commandType)
206+
{
207+
switch (commandType)
208+
{
209+
case CommandTypes.Alias:
210+
return Strings.UseFullyQualifiedCmdletNamesAliasError;
211+
case CommandTypes.Function:
212+
return Strings.UseFullyQualifiedCmdletNamesFunctionError;
213+
default:
214+
return Strings.UseFullyQualifiedCmdletNamesCommandError;
215+
}
216+
}
217+
218+
/// <summary>
219+
/// Resolves the command info for a given name using the shared runspace.
220+
/// </summary>
221+
/// <param name="commandName">The command name to resolve.</param>
222+
/// <returns>A cached result describing the resolved command.</returns>
223+
private ResolvedCommand ResolveCommand(string commandName)
224+
{
225+
var commandInfo = Helper.Instance.GetCommandInfo(commandName, CommandTypes.All);
226+
if (commandInfo == null)
227+
{
228+
return new ResolvedCommand(null, null, CommandTypes.Application);
229+
}
230+
231+
if (commandInfo.CommandType != CommandTypes.Cmdlet &&
232+
commandInfo.CommandType != CommandTypes.Function &&
233+
commandInfo.CommandType != CommandTypes.Alias)
234+
{
235+
return new ResolvedCommand(null, null, commandInfo.CommandType);
236+
}
237+
238+
var commandType = commandInfo.CommandType;
239+
240+
string moduleName = commandInfo.ModuleName;
241+
string resolvedName = commandInfo.Name;
242+
243+
if (commandInfo is AliasInfo aliasInfo)
244+
{
245+
if (aliasInfo.ResolvedCommand == null)
246+
{
247+
return new ResolvedCommand(null, null, commandType);
248+
}
249+
250+
resolvedName = aliasInfo.ResolvedCommand.Name;
251+
moduleName = aliasInfo.ResolvedCommand.ModuleName;
252+
}
253+
254+
if (string.IsNullOrEmpty(moduleName) || string.IsNullOrEmpty(resolvedName))
255+
{
256+
return new ResolvedCommand(null, null, commandType);
257+
}
258+
259+
return new ResolvedCommand($"{moduleName}\\{resolvedName}", moduleName, commandType);
260+
}
261+
262+
/// <summary>
263+
/// Holds the result of resolving a command name.
264+
/// </summary>
265+
private sealed class ResolvedCommand
266+
{
267+
public string FullyQualifiedName { get; }
268+
269+
public string ModuleName { get; }
270+
271+
public CommandTypes CommandType { get; }
272+
273+
public ResolvedCommand(string fullyQualifiedName, string moduleName, CommandTypes commandType)
274+
{
275+
FullyQualifiedName = fullyQualifiedName;
276+
ModuleName = moduleName;
277+
CommandType = commandType;
278+
}
279+
}
280+
281+
/// <summary>
282+
/// Retrieves the localized name of this rule.
283+
/// </summary>
284+
/// <returns>The localized name of this rule</returns>
285+
public override string GetName()
286+
{
287+
return string.Format(CultureInfo.CurrentCulture, Strings.NameSpaceFormat, GetSourceName(), Strings.UseFullyQualifiedCmdletNamesName);
288+
}
289+
290+
/// <summary>
291+
/// Retrieves the common name of this rule.
292+
/// </summary>
293+
/// <returns>The common name of this rule</returns>
294+
public override string GetCommonName()
295+
{
296+
return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesCommonName);
297+
}
298+
299+
/// <summary>
300+
/// Retrieves the localized description of this rule.
301+
/// </summary>
302+
/// <returns>The localized description of this rule</returns>
303+
public override string GetDescription()
304+
{
305+
return string.Format(CultureInfo.CurrentCulture, Strings.UseFullyQualifiedCmdletNamesDescription);
306+
}
307+
308+
/// <summary>
309+
/// Retrieves the source type of this rule.
310+
/// </summary>
311+
/// <returns>The source type of this rule</returns>
312+
public override SourceType GetSourceType()
313+
{
314+
return SourceType.Builtin;
315+
}
316+
317+
/// <summary>
318+
/// Retrieves the source name of this rule.
319+
/// </summary>
320+
/// <returns>The source name of this rule</returns>
321+
public override string GetSourceName()
322+
{
323+
return "PS";
324+
}
325+
326+
/// <summary>
327+
/// Retrieves the severity of this rule.
328+
/// </summary>
329+
/// <returns>The severity of this rule</returns>
330+
public override RuleSeverity GetSeverity()
331+
{
332+
return RuleSeverity.Warning;
333+
}
334+
}
335+
}

0 commit comments

Comments
 (0)