You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@skywalking.apache.org by wu...@apache.org on 2026/10/10 01:47:30 UTC

(skywalking-eyes) branch main updated: feat: add PowerShell license header support (#285)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-eyes.git


The following commit(s) were added to refs/heads/main by this push:
     new f6b7f44  feat: add PowerShell license header support (#285)
f6b7f44 is described below

commit f6b7f444be3b6d5dc790a9c9edb479892c7b0eaf
Author: Hengqian Ling <li...@outlook.com>
AuthorDate: Sat Oct 10 09:47:24 2026 +0800

    feat: add PowerShell license header support (#285)
---
 assets/languages.yaml       |   1 +
 assets/styles.yaml          |   6 ++
 go.mod                      |   2 +-
 pkg/comments/config_test.go |   3 +
 pkg/header/check.go         |  23 ++++-
 pkg/header/diff.go          |  15 ++-
 pkg/header/encoding.go      | 125 +++++++++++++++++++++++
 pkg/header/encoding_test.go | 238 ++++++++++++++++++++++++++++++++++++++++++++
 pkg/header/fix.go           |  10 ++
 pkg/header/fix_test.go      |  46 +++++++++
 pkg/license/norm.go         |   2 +
 pkg/license/norm_test.go    |  25 +++++
 12 files changed, 487 insertions(+), 9 deletions(-)

diff --git a/assets/languages.yaml b/assets/languages.yaml
index 27c9663..8a11a43 100644
--- a/assets/languages.yaml
+++ b/assets/languages.yaml
@@ -4307,6 +4307,7 @@ PowerShell:
   interpreters:
     - pwsh
   language_id: 293
+  comment_style_id: PowerShellStyle
 Prisma:
   type: data
   color: "#0c344b"
diff --git a/assets/styles.yaml b/assets/styles.yaml
index d62f46a..79ee050 100644
--- a/assets/styles.yaml
+++ b/assets/styles.yaml
@@ -125,3 +125,9 @@
   start: '/'
   middle: '/'
   end: '/'
+
+- id: PowerShellStyle
+  after: '(?m)\A#!.+$'
+  start: '<#'
+  middle: ~
+  end: '#>'
diff --git a/go.mod b/go.mod
index ed1d369..e04d9fb 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
 	golang.org/x/net v0.56.0
 	golang.org/x/oauth2 v0.27.0
 	golang.org/x/sync v0.21.0
+	golang.org/x/text v0.39.0
 	golang.org/x/tools v0.47.0
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -52,6 +53,5 @@ require (
 	golang.org/x/crypto v0.53.0 // indirect
 	golang.org/x/mod v0.37.0 // indirect
 	golang.org/x/sys v0.46.0 // indirect
-	golang.org/x/text v0.39.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 )
diff --git a/pkg/comments/config_test.go b/pkg/comments/config_test.go
index 9bfea88..7eb367f 100644
--- a/pkg/comments/config_test.go
+++ b/pkg/comments/config_test.go
@@ -53,6 +53,9 @@ func TestCommentStyle(t *testing.T) {
 	}{
 		{filename: "Test.java", commentStyleID: "SlashAsterisk"},
 		{filename: "Test.py", commentStyleID: "PythonStyle"},
+		{filename: "Test.ps1", commentStyleID: "PowerShellStyle"},
+		{filename: "Test.psm1", commentStyleID: "PowerShellStyle"},
+		{filename: "Test.psd1", commentStyleID: "PowerShellStyle"},
 	}
 	for _, test := range tests {
 		t.Run(test.filename, func(t *testing.T) {
diff --git a/pkg/header/check.go b/pkg/header/check.go
index c5008af..de3870b 100644
--- a/pkg/header/check.go
+++ b/pkg/header/check.go
@@ -255,18 +255,31 @@ func CheckFile(file string, config *ConfigHeader, result *Result) error {
 	if err != nil {
 		return err
 	}
-	if t := http.DetectContentType(bs); !strings.HasPrefix(t, "text/") {
-		logger.Log.Debugln("Ignoring file:", file, "; type:", t)
+
+	content, encoding, err := decodeContent(bs)
+	if err != nil {
+		// Report the file instead of aborting the whole check. Fixing it is
+		// rejected later by InsertComment, which leaves the file untouched.
+		logger.Log.Warnln("Failed to decode file:", file, "; error:", err)
+		result.Fail(file)
 		return nil
 	}
+	// A BOM already implies textual content, so only files without one need
+	// the MIME sniffing.
+	if encoding == encodingRaw {
+		if t := http.DetectContentType(bs); !strings.HasPrefix(t, "text/") {
+			logger.Log.Debugln("Ignoring file:", file, "; type:", t)
+			return nil
+		}
+	}
 
-	content := lcs.NormalizeHeader(string(bs))
+	normalized := lcs.NormalizeHeader(string(content))
 	expected, pattern := config.NormalizedLicense(), config.NormalizedPattern()
 
-	if satisfy(content, config, expected, pattern) {
+	if satisfy(normalized, config, expected, pattern) {
 		result.Succeed(file)
 	} else {
-		logger.Log.Debugln("Content is:", content)
+		logger.Log.Debugln("Content is:", normalized)
 
 		result.Fail(file)
 	}
diff --git a/pkg/header/diff.go b/pkg/header/diff.go
index 51a91ea..2e1dec5 100644
--- a/pkg/header/diff.go
+++ b/pkg/header/diff.go
@@ -48,11 +48,20 @@ func DiffFile(file string, config *ConfigHeader) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	if t := http.DetectContentType(bs); !strings.HasPrefix(t, "text/") {
-		return "", fmt.Errorf("not a text file: %v (%v)", file, t)
+
+	decoded, encoding, err := decodeContent(bs)
+	if err != nil {
+		return "", fmt.Errorf("failed to decode %v: %w", file, err)
+	}
+	// A BOM already implies textual content, so only files without one need
+	// the MIME sniffing.
+	if encoding == encodingRaw {
+		if t := http.DetectContentType(bs); !strings.HasPrefix(t, "text/") {
+			return "", fmt.Errorf("not a text file: %v (%v)", file, t)
+		}
 	}
 
-	content := lcs.NormalizeHeader(string(bs))
+	content := lcs.NormalizeHeader(string(decoded))
 	if satisfy(content, config, expected, config.NormalizedPattern()) {
 		return "", nil
 	}
diff --git a/pkg/header/encoding.go b/pkg/header/encoding.go
new file mode 100644
index 0000000..5f7cc98
--- /dev/null
+++ b/pkg/header/encoding.go
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package header
+
+import (
+	"bytes"
+	"fmt"
+
+	"golang.org/x/text/encoding/unicode"
+	"golang.org/x/text/transform"
+)
+
+// fileEncoding identifies the byte encoding of a file so that header fixes can
+// preserve it, including its byte-order mark (BOM).
+type fileEncoding int
+
+const (
+	encodingRaw fileEncoding = iota // UTF-8 without a BOM
+	encodingUTF8BOM
+	encodingUTF16LE
+	encodingUTF16BE
+)
+
+var (
+	utf8BOM    = []byte{0xEF, 0xBB, 0xBF}
+	utf16LEBOM = []byte{0xFF, 0xFE}
+	utf16BEBOM = []byte{0xFE, 0xFF}
+	utf32LEBOM = []byte{0xFF, 0xFE, 0x00, 0x00}
+	utf32BEBOM = []byte{0x00, 0x00, 0xFE, 0xFF}
+)
+
+// decodeContent converts the file content into UTF-8 and reports the encoding
+// it was written in. Files without a BOM are returned unchanged, because their
+// encoding cannot be determined reliably (e.g. UTF-16 without a BOM).
+func decodeContent(content []byte) ([]byte, fileEncoding, error) {
+	switch {
+	case bytes.HasPrefix(content, utf32LEBOM), bytes.HasPrefix(content, utf32BEBOM):
+		// The UTF-32LE BOM shares the UTF-16LE prefix, so check it first.
+		return nil, encodingRaw, fmt.Errorf("unsupported encoding: UTF-32")
+	case bytes.HasPrefix(content, utf16LEBOM):
+		decoded, err := decodeWith(content[len(utf16LEBOM):], unicode.LittleEndian)
+		return decoded, encodingUTF16LE, err
+	case bytes.HasPrefix(content, utf16BEBOM):
+		decoded, err := decodeWith(content[len(utf16BEBOM):], unicode.BigEndian)
+		return decoded, encodingUTF16BE, err
+	case bytes.HasPrefix(content, utf8BOM):
+		return content[len(utf8BOM):], encodingUTF8BOM, nil
+	default:
+		return content, encodingRaw, nil
+	}
+}
+
+// decodeContentLossless is decodeContent for callers that rewrite the file. The
+// UTF-16 decoder replaces malformed input, such as an odd trailing byte or an
+// unpaired surrogate, with U+FFFD instead of failing, so content that does not
+// survive a round trip is rejected to keep the original bytes intact.
+func decodeContentLossless(content []byte) ([]byte, fileEncoding, error) {
+	decoded, encoding, err := decodeContent(content)
+	if err != nil {
+		return nil, encoding, err
+	}
+	encoded, err := encodeContent(decoded, encoding)
+	if err != nil {
+		return nil, encoding, err
+	}
+	// Only UTF-16 decoding is lossy, the other encodings always round trip.
+	if !bytes.Equal(encoded, content) {
+		return nil, encoding, fmt.Errorf("malformed UTF-16 content")
+	}
+	return decoded, encoding, nil
+}
+
+func decodeWith(content []byte, order unicode.Endianness) ([]byte, error) {
+	decoded, _, err := transform.Bytes(unicode.UTF16(order, unicode.IgnoreBOM).NewDecoder(), content)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decode UTF-16 content: %w", err)
+	}
+	return decoded, nil
+}
+
+// encodeContent converts UTF-8 content back into the given encoding, restoring
+// the BOM at byte 0.
+func encodeContent(content []byte, encoding fileEncoding) ([]byte, error) {
+	switch encoding {
+	case encodingUTF16LE:
+		encoded, err := encodeWith(content, unicode.LittleEndian)
+		if err != nil {
+			return nil, err
+		}
+		return append(append([]byte{}, utf16LEBOM...), encoded...), nil
+	case encodingUTF16BE:
+		encoded, err := encodeWith(content, unicode.BigEndian)
+		if err != nil {
+			return nil, err
+		}
+		return append(append([]byte{}, utf16BEBOM...), encoded...), nil
+	case encodingUTF8BOM:
+		return append(append([]byte{}, utf8BOM...), content...), nil
+	default:
+		return content, nil
+	}
+}
+
+func encodeWith(content []byte, order unicode.Endianness) ([]byte, error) {
+	encoded, _, err := transform.Bytes(unicode.UTF16(order, unicode.IgnoreBOM).NewEncoder(), content)
+	if err != nil {
+		return nil, fmt.Errorf("failed to encode UTF-16 content: %w", err)
+	}
+	return encoded, nil
+}
diff --git a/pkg/header/encoding_test.go b/pkg/header/encoding_test.go
new file mode 100644
index 0000000..b1ea937
--- /dev/null
+++ b/pkg/header/encoding_test.go
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package header
+
+import (
+	"bytes"
+	"encoding/binary"
+	"os"
+	"path/filepath"
+	"testing"
+	"unicode/utf16"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/skywalking-eyes/pkg/comments"
+)
+
+// encodeUTF16 encodes s as UTF-16 with the given byte order, prefixed with bom.
+// It is independent of the implementation under test, which uses x/text.
+func encodeUTF16(s string, order binary.ByteOrder, bom []byte) []byte {
+	units := utf16.Encode([]rune(s))
+	out := make([]byte, 0, len(bom)+len(units)*2)
+	out = append(out, bom...)
+	var buf [2]byte
+	for _, unit := range units {
+		order.PutUint16(buf[:], unit)
+		out = append(out, buf[0], buf[1])
+	}
+	return out
+}
+
+func decodeUTF16(t *testing.T, content []byte, order binary.ByteOrder) string {
+	t.Helper()
+	require.Zero(t, len(content)%2, "UTF-16 content must have an even number of bytes")
+	units := make([]uint16, len(content)/2)
+	for i := range units {
+		units[i] = order.Uint16(content[i*2:])
+	}
+	return string(utf16.Decode(units))
+}
+
+// TestFixPreservesFileEncoding ensures that fixing a BOM encoded file keeps
+// its encoding and byte-order mark, see
+// https://github.com/apache/skywalking-eyes/pull/285#issuecomment-6081296040.
+func TestFixPreservesFileEncoding(t *testing.T) {
+	const source = "#!/usr/bin/env pwsh\nWrite-Host 'Hello World'\n"
+	tests := []struct {
+		name  string
+		bom   []byte
+		order binary.ByteOrder
+	}{
+		{name: "UTF-16LE with BOM", bom: []byte{0xFF, 0xFE}, order: binary.LittleEndian},
+		{name: "UTF-16BE with BOM", bom: []byte{0xFE, 0xFF}, order: binary.BigEndian},
+		{name: "UTF-8 with BOM", bom: []byte{0xEF, 0xBB, 0xBF}},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			content := append(append([]byte{}, test.bom...), source...)
+			if test.order != nil {
+				content = encodeUTF16(source, test.order, test.bom)
+			}
+			file := filepath.Join(t.TempDir(), "test.ps1")
+			require.NoError(t, os.WriteFile(file, content, 0o600))
+
+			config := &ConfigHeader{
+				License: LicenseConfig{Content: "Apache License 2.0"},
+				Paths:   []string{"**"},
+			}
+			require.NoError(t, config.Finalize())
+
+			var before Result
+			require.NoError(t, CheckFile(file, config, &before))
+			require.True(t, before.HasFailure(), "a headerless file must not pass the check")
+
+			var fixed Result
+			require.NoError(t, Fix(file, config, &fixed))
+			require.Equal(t, []string{file}, fixed.Fixed)
+
+			after, err := os.ReadFile(file)
+			require.NoError(t, err)
+			require.True(t, bytes.HasPrefix(after, test.bom), "the BOM must stay at byte 0")
+
+			text := string(after[len(test.bom):])
+			if test.order != nil {
+				text = decodeUTF16(t, after[len(test.bom):], test.order)
+			}
+			require.Contains(t, text, "#!/usr/bin/env pwsh\n")
+			require.Contains(t, text, "<#\n Apache License 2.0\n#>\n")
+			require.Contains(t, text, "Write-Host 'Hello World'")
+
+			var checked Result
+			require.NoError(t, CheckFile(file, config, &checked))
+			require.False(t, checked.HasFailure(), "the fixed file must pass the check")
+			require.Equal(t, []string{file}, checked.Success)
+
+			// Fixing an already valid file must not change it.
+			var again Result
+			require.NoError(t, Fix(file, config, &again))
+			unchanged, err := os.ReadFile(file)
+			require.NoError(t, err)
+			require.Equal(t, after, unchanged)
+		})
+	}
+}
+
+func TestHeaderOperationsRejectUTF32(t *testing.T) {
+	const source = "Write-Host 'Hello'\n"
+	encodings := []struct {
+		name  string
+		bom   []byte
+		order binary.ByteOrder
+	}{
+		{name: "UTF-32LE", bom: []byte{0xFF, 0xFE, 0x00, 0x00}, order: binary.LittleEndian},
+		{name: "UTF-32BE", bom: []byte{0x00, 0x00, 0xFE, 0xFF}, order: binary.BigEndian},
+	}
+	// The check reports the file as invalid without aborting the whole run,
+	// while the operations that would rewrite it fail and leave it untouched.
+	operations := []struct {
+		name    string
+		run     func(string, *ConfigHeader, *Result) error
+		wantErr bool
+	}{
+		{name: "check", run: CheckFile},
+		{name: "diff", run: func(file string, config *ConfigHeader, _ *Result) error {
+			_, err := DiffFile(file, config)
+			return err
+		}, wantErr: true},
+		{name: "fix", run: Fix, wantErr: true},
+		{name: "insert", run: func(file string, config *ConfigHeader, result *Result) error {
+			return InsertComment(file, comments.FileCommentStyle(file), config, result)
+		}, wantErr: true},
+	}
+
+	for _, encoding := range encodings {
+		t.Run(encoding.name, func(t *testing.T) {
+			content := append([]byte{}, encoding.bom...)
+			var buf [4]byte
+			for _, b := range []byte(source) {
+				encoding.order.PutUint32(buf[:], uint32(b))
+				content = append(content, buf[:]...)
+			}
+
+			for _, operation := range operations {
+				t.Run(operation.name, func(t *testing.T) {
+					file := filepath.Join(t.TempDir(), "test.ps1")
+					require.NoError(t, os.WriteFile(file, content, 0o600))
+					config := &ConfigHeader{
+						License: LicenseConfig{Content: "Apache License 2.0"},
+						Paths:   []string{"**"},
+					}
+					require.NoError(t, config.Finalize())
+
+					var result Result
+					err := operation.run(file, config, &result)
+					if operation.wantErr {
+						require.ErrorContains(t, err, "unsupported encoding: UTF-32")
+					} else {
+						require.NoError(t, err)
+						require.Equal(t, []string{file}, result.Failure)
+					}
+					require.Empty(t, result.Success)
+					require.Empty(t, result.Fixed)
+					after, err := os.ReadFile(file)
+					require.NoError(t, err)
+					require.Equal(t, content, after, "unsupported files must remain unchanged")
+				})
+			}
+		})
+	}
+}
+
+// TestFixRejectsMalformedUTF16 ensures that fixing a UTF-16 file the decoder
+// cannot convert losslessly leaves it untouched instead of replacing the
+// malformed bytes with U+FFFD.
+func TestFixRejectsMalformedUTF16(t *testing.T) {
+	const source = "Write-Host 'Hello'\n"
+	orders := []struct {
+		name  string
+		bom   []byte
+		order binary.ByteOrder
+	}{
+		{name: "UTF-16LE", bom: []byte{0xFF, 0xFE}, order: binary.LittleEndian},
+		{name: "UTF-16BE", bom: []byte{0xFE, 0xFF}, order: binary.BigEndian},
+	}
+	malformations := []struct {
+		name string
+		tail func(binary.ByteOrder) []byte
+	}{
+		{name: "odd trailing byte", tail: func(binary.ByteOrder) []byte { return []byte{0x41} }},
+		{name: "unpaired surrogate", tail: func(order binary.ByteOrder) []byte {
+			var buf [2]byte
+			order.PutUint16(buf[:], 0xD800)
+			return append(buf[:], encodeUTF16("x\n", order, nil)...)
+		}},
+	}
+
+	for _, order := range orders {
+		for _, malformation := range malformations {
+			t.Run(order.name+" with "+malformation.name, func(t *testing.T) {
+				content := append(encodeUTF16(source, order.order, order.bom), malformation.tail(order.order)...)
+				file := filepath.Join(t.TempDir(), "test.ps1")
+				require.NoError(t, os.WriteFile(file, content, 0o600))
+				config := &ConfigHeader{
+					License: LicenseConfig{Content: "Apache License 2.0"},
+					Paths:   []string{"**"},
+				}
+				require.NoError(t, config.Finalize())
+
+				var checked Result
+				require.NoError(t, CheckFile(file, config, &checked))
+				require.Equal(t, []string{file}, checked.Failure)
+
+				var fixed Result
+				require.ErrorContains(t, Fix(file, config, &fixed), "malformed UTF-16 content")
+				require.Empty(t, fixed.Fixed)
+				after, err := os.ReadFile(file)
+				require.NoError(t, err)
+				require.Equal(t, content, after, "malformed files must remain unchanged")
+			})
+		}
+	}
+}
diff --git a/pkg/header/fix.go b/pkg/header/fix.go
index 18cf067..da65b3c 100644
--- a/pkg/header/fix.go
+++ b/pkg/header/fix.go
@@ -56,6 +56,11 @@ func InsertComment(file string, style *comments.CommentStyle, config *ConfigHead
 		return err
 	}
 
+	content, encoding, err := decodeContentLossless(content)
+	if err != nil {
+		return fmt.Errorf("failed to decode %v: %w", file, err)
+	}
+
 	licenseHeader, err := GenerateLicenseHeader(style, config)
 	if err != nil {
 		return err
@@ -63,6 +68,11 @@ func InsertComment(file string, style *comments.CommentStyle, config *ConfigHead
 
 	content = rewriteContent(style, content, licenseHeader, config.LicensePattern(style))
 
+	content, err = encodeContent(content, encoding)
+	if err != nil {
+		return fmt.Errorf("failed to encode %v: %w", file, err)
+	}
+
 	if err := os.WriteFile(file, content, stat.Mode()); err != nil { //nolint:gosec // path from tool's own file scanner
 		return err
 	}
diff --git a/pkg/header/fix_test.go b/pkg/header/fix_test.go
index c877944..82a786e 100644
--- a/pkg/header/fix_test.go
+++ b/pkg/header/fix_test.go
@@ -82,6 +82,16 @@ func TestFix(t *testing.T) {
 /   http://www.apache.org/licenses/LICENSE-2.0
 / Apache License 2.0
 
+`,
+		},
+		{
+			filename: "test.ps1",
+			comments: `<#
+ Apache License 2.0
+   http://www.apache.org/licenses/LICENSE-2.0
+ Apache License 2.0
+#>
+
 `,
 		},
 	}
@@ -133,6 +143,42 @@ if __name__ == '__main__':
 
 if __name__ == '__main__':
     print('Hello World')
+`},
+		{
+			name:  "PowerShell with interpreter binary",
+			style: comments.FileCommentStyle("test.ps1"),
+			content: `#!/usr/bin/env pwsh
+Write-Host 'Hello World'
+`,
+			licenseHeader: getLicenseHeader("test.ps1", t.Error),
+			expectedContent: `#!/usr/bin/env pwsh
+<#
+ Apache License 2.0
+   http://www.apache.org/licenses/LICENSE-2.0
+ Apache License 2.0
+#>
+
+Write-Host 'Hello World'
+`},
+		{
+			name:  "PowerShell with shebang-like string mid file",
+			style: comments.FileCommentStyle("test.ps1"),
+			content: `function Invoke-Native {
+    $script = '#!/usr/bin/env pwsh'
+    Write-Host 'Hello World'
+}
+`,
+			licenseHeader: getLicenseHeader("test.ps1", t.Error),
+			expectedContent: `<#
+ Apache License 2.0
+   http://www.apache.org/licenses/LICENSE-2.0
+ Apache License 2.0
+#>
+
+function Invoke-Native {
+    $script = '#!/usr/bin/env pwsh'
+    Write-Host 'Hello World'
+}
 `},
 		{
 			name:  "Python with shebang-like string mid file",
diff --git a/pkg/license/norm.go b/pkg/license/norm.go
index 5a7dac9..04136a0 100644
--- a/pkg/license/norm.go
+++ b/pkg/license/norm.go
@@ -43,6 +43,8 @@ var (
 
 	// 6. Code Comment Indicators (https://spdx.dev/license-list/matching-guidelines.)
 	commentIndicators = []*regexp.Regexp{
+		regexp.MustCompile(`(?m)^\s*<#+`),   // <#
+		regexp.MustCompile(`(?m)^\s*#+>`),   // #>, must come before the # pattern
 		regexp.MustCompile(`(?m)^\s*#+`),    // #
 		regexp.MustCompile(`(?m)^\s*//+`),   // //
 		regexp.MustCompile(`(?m)^\s*"""+`),  // """
diff --git a/pkg/license/norm_test.go b/pkg/license/norm_test.go
index 7f0d33f..4e8c556 100644
--- a/pkg/license/norm_test.go
+++ b/pkg/license/norm_test.go
@@ -190,6 +190,31 @@ func TestCommentLeadingCharsNormalizer(t *testing.T) {
 @REM See the License for the specific language governing permissions and
 @REM limitations under the License.
 @REM
+`,
+		},
+		{
+			name: "PowerShell",
+			// Like PythonTripleQuotes below, the block delimiters leave a
+			// leading newline: the middle lines carry no comment marker
+			// that would consume it.
+			want: "\n" + want,
+			text: `
+<#
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+#>
 `,
 		},
 		{