Skip to content
Merged
7 changes: 7 additions & 0 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Additionally, the implicit project file has the following customizations:
string? directoryPath = AppContext.GetData("EntryPointFileDirectoryPath") as string;
```

- `EntryPointFilePath` property is set to the entry-point file path and is made visible to analyzers via `CompilerVisibleProperty`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a part of me that really feels like this should be something more like a CompilationOption rather than just being passed along as an MSBuild property but I'm not really sure what we'd actually gain with that.

Copy link
Copy Markdown
Member Author

@jjonescz jjonescz Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we have the feature flag FileBasedProgram (which causes roslyn to allow #: and #! directives). We could pass the entry point file path as its value I guess. Feature flags are part of parse options. But I also don't know what we'd gain with that.

Copy link
Copy Markdown
Member

@RikkiGibson RikkiGibson Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

csc has the -main switch today, but, it takes a type name, not a file path..

-main:<type>                  Specify the type that contains the entry point
                              (ignore all other possible entry points) (Short
                              form: -m)

I think that using a compiler-visible msbuild property is the right notch on the dial for the time being.


- `FileBasedProgram` property is set to `true` and can be used by SDK targets to detect file-based apps.

- `DisableDefaultItemsInProjectFolder` property is set to `true` which results in `EnableDefaultItems=false` by default
Expand Down Expand Up @@ -270,6 +272,11 @@ Along with `#:`, the language also ignores `#!` which could be then used for [sh
Console.WriteLine("Hello");
```

When a file-based program uses [`#:include`](#multiple-files) directives to include additional files,
the entry point file should start with `#!` to clearly distinguish it from included files.
This helps IDEs to properly handle multi-file scenarios and discover entry points.
The analyzer **CA2266** reports a warning if the entry point file is missing the shebang line in this scenario.

## Implementation

The build is performed using MSBuild APIs on in-memory project files.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ private string GetGeneratedMSBuildEditorConfigContent()
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property.EntryPointFilePath = {EntryPointFileFullPath}
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = {FileNameWithoutExtension}
build_property.ProjectDir = {BaseDirectoryWithTrailingSeparator}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.NetCore.Analyzers;
using Microsoft.NetCore.Analyzers.Usage;

namespace Microsoft.NetCore.CSharp.Analyzers.Usage
{
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
public sealed class CSharpMissingShebangInFileBasedProgramFixer : MissingShebangInFileBasedProgramFixer
{
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
{
return;
}

var codeAction = CodeAction.Create(
MicrosoftNetCoreAnalyzersResources.MissingShebangInFileBasedProgramCodeFixTitle,
async ct =>
{
var options = await context.Document.GetOptionsAsync(ct).ConfigureAwait(false);
var eol = options.GetOption(FormattingOptions.NewLine, LanguageNames.CSharp);
var shebangTrivia = SyntaxFactory.ParseLeadingTrivia("#!/usr/bin/env dotnet" + eol);
var firstToken = root.GetFirstToken(includeZeroWidth: true);
var newFirstToken = firstToken.WithLeadingTrivia(shebangTrivia.AddRange(firstToken.LeadingTrivia));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where should this go relative to copyright headers?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#! always needs to be first otherwise it wouldn't be recognized by shells. I remember we already discussed this and the copyright header analyzers should be already handling #! correctly.

var newRoot = root.ReplaceToken(firstToken, newFirstToken)
.WithAdditionalAnnotations(Formatter.Annotation);
return context.Document.WithSyntaxRoot(newRoot);
},
MicrosoftNetCoreAnalyzersResources.MissingShebangInFileBasedProgramCodeFixTitle);
context.RegisterCodeFix(codeAction, context.Diagnostics);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.NetCore.Analyzers.Usage;

namespace Microsoft.NetCore.CSharp.Analyzers.Usage
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class CSharpMissingShebangInFileBasedProgram : MissingShebangInFileBasedProgram
{
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

context.RegisterCompilationStartAction(context =>
{
var entryPointFilePath = context.Options.GetMSBuildPropertyValue(
MSBuildPropertyOptionNames.EntryPointFilePath, context.Compilation);
if (string.IsNullOrEmpty(entryPointFilePath))
{
return;
}

// Count non-generated trees upfront so we can report directly
// from a SyntaxTreeAction without needing CompilationEnd.
// We avoid CompilationEnd so diagnostics appear as live IDE diagnostics.
// We replicate Roslyn's generated code detection here because
// Compilation.SyntaxTrees is the raw set (unlike RegisterSyntaxTreeAction
// which gets automatic filtering via ConfigureGeneratedCodeAnalysis).
int nonGeneratedTreeCount = 0;
foreach (var tree in context.Compilation.SyntaxTrees)
{
if (IsGeneratedCode(tree, context.Options.AnalyzerConfigOptionsProvider))
{
continue;
}

nonGeneratedTreeCount++;
}

// Only report when there are multiple non-generated files
// (i.e., #:include directives are used).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this has the effect of also detecting when a Directory.Build.props adds a compile item? Is that right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if it is a goal to handle that case, then, it should have a test

// Single-file programs don't need a shebang to distinguish the entry point.
if (nonGeneratedTreeCount <= 1)
{
return;
}

context.RegisterSyntaxTreeAction(context =>
{
if (!context.Tree.FilePath.Equals(entryPointFilePath, StringComparison.Ordinal))
{
return;
}

var root = context.Tree.GetRoot(context.CancellationToken);
if (root.GetLeadingTrivia().Any(SyntaxKind.ShebangDirectiveTrivia))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we counting on the fact that if the #! appears somewhere besides position 0, then the compiler reports an error?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll add a test to demonstrate.

{
return;
}

var location = root.GetFirstToken(includeZeroWidth: true).GetLocation();
context.ReportDiagnostic(location.CreateDiagnostic(Rule));
});
});
}

/// <summary>
/// Replicates Roslyn's generated code detection which checks:
/// the <c>generated_code</c> analyzer config option,
/// common file name patterns, and <c>&lt;auto-generated&gt;</c> comment headers.
/// Based on <see href="https://github.com/dotnet/roslyn/blob/0504782ef845507260874f2efc253b36d1775685/src/Compilers/Core/Portable/SourceGeneration/GeneratedCodeUtilities.cs">GeneratedCodeUtilities</see>.
/// </summary>
private static bool IsGeneratedCode(SyntaxTree tree, AnalyzerConfigOptionsProvider optionsProvider)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like hardcoding this knowledge here, but don't know how to better do this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first I thought: just indicate that the analyzer shouldn't run on generated code, and see if you get called back for a given syntax tree or not. But I think that doesn't work, since it would need the "compilation end" callback to run.

I think copying the implementation like you did is the best we can do for now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend adding a permalink here to the location in Roslyn this was based on though.

{
if (optionsProvider.GetOptions(tree)
.TryGetValue("generated_code", out var generatedValue) &&
generatedValue.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return true;
}

var filePath = tree.FilePath;
if (!string.IsNullOrEmpty(filePath))
{
var fileName = Path.GetFileName(filePath);
if (fileName.StartsWith("TemporaryGeneratedFile_", StringComparison.OrdinalIgnoreCase))
{
return true;
}

var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
if (nameWithoutExtension.EndsWith(".designer", StringComparison.OrdinalIgnoreCase) ||
nameWithoutExtension.EndsWith(".generated", StringComparison.OrdinalIgnoreCase) ||
nameWithoutExtension.EndsWith(".g", StringComparison.OrdinalIgnoreCase) ||
nameWithoutExtension.EndsWith(".g.i", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

// Check for <auto-generated> or <autogenerated> comment at the top of the file.
foreach (var trivia in tree.GetRoot().GetLeadingTrivia())
{
switch (trivia.Kind())
{
case SyntaxKind.SingleLineCommentTrivia:
case SyntaxKind.MultiLineCommentTrivia:
var text = trivia.ToString();
if (text.Contains("<auto-generated") || text.Contains("<autogenerated"))
{
return true;
}
break;
case SyntaxKind.WhitespaceTrivia:
case SyntaxKind.EndOfLineTrivia:
continue;
default:
return false;
}
}

return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2742,6 +2742,18 @@ Comparing a span to 'null' or 'default' might not do what you intended. 'default
|CodeFix|True|
---

## [CA2266](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2266): File-based program entry point should start with '#!'

When a file-based program consists of multiple files, the entry point file should start with a shebang ('#!') line to clearly distinguish it from other included files.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that we may also want this when #:project or #:ref are used, so that find all references on symbols in the referenced projects works properly.

However, I don't wish to block on that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, but I think that can be implemented as a follow up (filed #53749), since this PR should unblock us removing the experimental opt-in from #:include, so it'd be good to merge just that part first.


|Item|Value|
|-|-|
|Category|Usage|
|Enabled|True|
|Severity|Warning|
|CodeFix|True|
---

## [CA2300](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2300): Do not use insecure deserializer BinaryFormatter

The method '{0}' is insecure when deserializing untrusted data. If you need to instead detect BinaryFormatter deserialization without a SerializationBinder set, then disable rule CA2300, and enable rules CA2301 and CA2302.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,25 @@
]
}
},
"CA2266": {
"id": "CA2266",
"shortDescription": "File-based program entry point should start with '#!'",
"fullDescription": "When a file-based program consists of multiple files, the entry point file should start with a shebang ('#!') line to clearly distinguish it from other included files.",
"defaultLevel": "warning",
"helpUri": "https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2266",
"properties": {
"category": "Usage",
"isEnabledByDefault": true,
"typeName": "CSharpMissingShebangInFileBasedProgram",
"languages": [
"C#"
],
"tags": [
"Telemetry",
"EnabledRuleInAggressiveMode"
]
}
},
"CA2352": {
"id": "CA2352",
"shortDescription": "Unsafe DataSet or DataTable in serializable type can be vulnerable to remote code execution attacks",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,4 @@ CA1875 | Performance | Info | UseRegexMembers, [Documentation](https://learn.mic
CA2023 | Reliability | Warning | LoggerMessageDefineAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2023)
CA2024 | Reliability | Warning | DoNotUseEndOfStreamInAsyncMethods, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2024)
CA2025 | Reliability | Disabled | DoNotPassDisposablesIntoUnawaitedTasksAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2025)
CA2266 | Usage | Warning | MissingShebangInFileBasedProgram
Original file line number Diff line number Diff line change
Expand Up @@ -2216,4 +2216,16 @@ Widening and user defined conversions are not supported with generic types.</val
<data name="DoNotUseThreadVolatileReadWriteCodeFixTitle" xml:space="preserve">
<value>Replace obsolete call</value>
</data>
<data name="MissingShebangInFileBasedProgramTitle" xml:space="preserve">
<value>File-based program entry point should start with '#!'</value>
</data>
<data name="MissingShebangInFileBasedProgramDescription" xml:space="preserve">
<value>When a file-based program consists of multiple files, the entry point file should start with a shebang ('#!') line to clearly distinguish it from other included files.</value>
</data>
<data name="MissingShebangInFileBasedProgramMessage" xml:space="preserve">
<value>File-based program entry point should start with '#!'</value>
</data>
<data name="MissingShebangInFileBasedProgramCodeFixTitle" xml:space="preserve">
<value>Add '#!' (shebang)</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis.CodeFixes;

namespace Microsoft.NetCore.Analyzers.Usage
{
public abstract class MissingShebangInFileBasedProgramFixer : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(MissingShebangInFileBasedProgram.RuleId);

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Immutable;
using Analyzer.Utilities;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Microsoft.NetCore.Analyzers.Usage
{
using static MicrosoftNetCoreAnalyzersResources;

public abstract class MissingShebangInFileBasedProgram : DiagnosticAnalyzer
{
internal const string RuleId = "CA2266";

internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
RuleId,
CreateLocalizableResourceString(nameof(MissingShebangInFileBasedProgramTitle)),
CreateLocalizableResourceString(nameof(MissingShebangInFileBasedProgramMessage)),
DiagnosticCategory.Usage,
RuleLevel.BuildWarning,
CreateLocalizableResourceString(nameof(MissingShebangInFileBasedProgramDescription)),
isPortedFxCopRule: false,
isDataflowRule: false,
isReportedAtCompilationEnd: false);

public sealed override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading