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 f4b890e  feat: add `header diff` command to show header differences 
(#276)
f4b890e is described below

commit f4b890e8d2b717a62f75d3c6d0965cc0754a9c1d
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed Jun 10 20:37:08 2026 +0800

    feat: add `header diff` command to show header differences (#276)
    
    The new command shows where the license headers of the invalid files
    differ from the license configured in the config file, to help
    understand why `header check` fails, e.g. to spot a typo in an
    existing license header.
    
    The texts are compared in the same normalized forms that the check
    command compares, so every difference shown is a real cause of the
    check failure.
    
    Resolves apache/skywalking#8002
---
 README.md               |  32 +++++++++-
 action.yml              |   2 +-
 commands/header.go      |   1 +
 commands/header_diff.go |  87 +++++++++++++++++++++++++++
 go.mod                  |   2 +-
 header/action.yml       |   2 +-
 pkg/header/diff.go      | 152 ++++++++++++++++++++++++++++++++++++++++++++++++
 pkg/header/diff_test.go | 121 ++++++++++++++++++++++++++++++++++++++
 8 files changed, 394 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 03a2add..de938af 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ To check license headers in GitHub Actions, add a step in 
your GitHub workflow.
       # log: debug # optional: set the log level. The default value is `info`.
       # config: .licenserc.yaml # optional: set the config file. The default 
value is `.licenserc.yaml`.
       # token: # optional: the token that license eye uses when it needs to 
comment on the pull request. Set to empty ("") to disable commenting on pull 
request. The default value is ${{ github.token }}
-      # mode: # optional: Which mode License-Eye should be run in. Choices are 
`check` or `fix`. The default value is `check`.
+      # mode: # optional: Which mode License-Eye should be run in. Choices are 
`check`, `fix` or `diff`. The default value is `check`.
 ```
 
 #### Fix License Headers
@@ -308,6 +308,34 @@ INFO Totally checked 20 files, valid: 10, invalid: 10, 
ignored: 0, fixed: 10
 
 </details>
 
+#### Diff License Header
+
+This command shows where the license headers of the invalid files differ from 
the license configured in the config file, to help understand why `header 
check` fails, for example, to spot a typo in an existing license header.
+
+```bash
+license-eye -c .licenserc.yaml header diff
+```
+
+<details>
+<summary>Header Diff Result</summary>
+
+For a `test.go` whose license header has a typo 
`wwwhttp://www.apache.org/licenses/LICENSE-2.0` in the license URL, and a 
`missing.py` that doesn't have a license header at all:
+
+```
+INFO Loading configuration from file: .licenserc.yaml
+missing.py:
+       [-licensed to the asf under one or more contributor license ... the 
specific language governing permissions and limitations under the license.-] ...
+test.go:
+       ... copy of the license at 
[-http://www.apache.org/licenses/license-2.0-] 
{+wwwhttp://www.apache.org/licenses/license-2.0+} unless required by applicable 
law ... and limitations under the license. ...
+INFO Totally checked 3 files, valid: 0, invalid: 2, ignored: 1, fixed: 0
+ERROR one or more files does not have a valid license header
+exit status 1
+```
+
+</details>
+
+The texts are compared in their normalized forms (comment markers stripped, 
whitespace flattened, case-insensitive, etc., the same forms that `header 
check` compares), so every difference shown is a real cause of the check 
failure: `[-text-]` marks text that is expected by the configured license but 
missing in the file, `{+text+}` marks text that is in the file but not expected 
by the configured license, and long runs of unchanged or missing words are 
collapsed into `...`.
+
 #### Resolve Dependencies' licenses
 
 This command assists human audits of the dependencies licenses. It's exit code 
is always 0.
@@ -858,7 +886,7 @@ header:
 
 ## Supported File Types
 
-The `header check` command theoretically supports all kinds of file types, 
while the supported file types of `header fix` command can be found [in this 
YAML file](assets/languages.yaml). In the YAML file, if the language has a 
non-empty property `comment_style_id`, and the comment style id is declared in 
[the comment styles file](assets/styles.yaml), then the language is supported 
by `fix` command.
+The `header check` and `header diff` commands theoretically support all kinds 
of file types, while the supported file types of `header fix` command can be 
found [in this YAML file](assets/languages.yaml). In the YAML file, if the 
language has a non-empty property `comment_style_id`, and the comment style id 
is declared in [the comment styles file](assets/styles.yaml), then the language 
is supported by `fix` command.
 
 - [assets/languages.yaml](assets/languages.yaml)
 
diff --git a/action.yml b/action.yml
index ada8764..b551350 100644
--- a/action.yml
+++ b/action.yml
@@ -37,7 +37,7 @@ inputs:
     default: ${{ github.token }}
   mode:
     description: |
-      Which mode License Eye should be run in. Choices are `check` or `fix`. 
The
+      Which mode License Eye should be run in. Choices are `check`, `fix` or 
`diff`. The
       default value is `check`.
     required: false
     default: check
diff --git a/commands/header.go b/commands/header.go
index 062f873..fa95349 100644
--- a/commands/header.go
+++ b/commands/header.go
@@ -33,4 +33,5 @@ var Header = &cobra.Command{
 func init() {
        Header.AddCommand(CheckCommand)
        Header.AddCommand(FixCommand)
+       Header.AddCommand(DiffCommand)
 }
diff --git a/commands/header_diff.go b/commands/header_diff.go
new file mode 100644
index 0000000..9ee4517
--- /dev/null
+++ b/commands/header_diff.go
@@ -0,0 +1,87 @@
+// 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 commands
+
+import (
+       "fmt"
+       "sort"
+       "strings"
+
+       "github.com/spf13/cobra"
+
+       "github.com/apache/skywalking-eyes/pkg/header"
+       "github.com/apache/skywalking-eyes/pkg/logger"
+)
+
+var DiffCommand = &cobra.Command{
+       Use:     "diff [paths...]",
+       Aliases: []string{"d"},
+       Long: "diff command walks the specified paths recursively and shows 
where the " +
+               "license headers of the invalid files differ from the license 
header in the " +
+               "config file, to help understand why the check command fails. " 
+
+               "Accepts files, directories, and glob patterns. " +
+               "If no paths are specified, checks the current directory " +
+               "recursively as defined in the config file. " +
+               "The texts are compared in the same normalized forms that the 
check command " +
+               "compares (comment markers stripped, whitespace flattened, 
case-insensitive, etc.), " +
+               "so every difference shown is a real cause of the check 
failure: " +
+               "[-text-] is expected by the configured license but missing in 
the file, " +
+               "{+text+} is in the file but not expected by the configured 
license.",
+       RunE: func(_ *cobra.Command, args []string) error {
+               hasErrors := false
+               var errors []string
+               for _, h := range Config.Headers() {
+                       var result header.Result
+
+                       if len(args) > 0 {
+                               logger.Log.Debugln("Overriding paths with 
command line args.")
+                               h.Paths = args
+                       }
+
+                       if err := header.Check(h, &result); err != nil {
+                               return err
+                       }
+
+                       sort.Strings(result.Failure)
+                       for _, file := range result.Failure {
+                               diff, err := header.DiffFile(file, h)
+                               if err != nil {
+                                       errors = append(errors, err.Error())
+                                       continue
+                               }
+                               if diff == "" {
+                                       continue
+                               }
+                               fmt.Printf("%v:\n\t%v\n", file, diff)
+                       }
+
+                       logger.Log.Infoln(result.String())
+
+                       if result.HasFailure() {
+                               hasErrors = true
+                       }
+               }
+               if len(errors) > 0 {
+                       return fmt.Errorf("%s", strings.Join(errors, "\n"))
+               }
+               if hasErrors {
+                       return fmt.Errorf("one or more files does not have a 
valid license header")
+               }
+               return nil
+       },
+}
diff --git a/go.mod b/go.mod
index b27a801..3bfc3c7 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
        github.com/go-git/go-git/v5 v5.19.1
        github.com/google/go-github/v33 v33.0.0
        github.com/google/licensecheck v0.3.1
+       github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
        github.com/sirupsen/logrus v1.9.3
        github.com/spf13/cobra v1.6.1
        github.com/stretchr/testify v1.11.1
@@ -43,7 +44,6 @@ require (
        github.com/mitchellh/reflectwalk v1.0.2 // indirect
        github.com/pjbgf/sha1cd v0.6.0 // indirect
        github.com/pmezard/go-difflib v1.0.0 // indirect
-       github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // 
indirect
        github.com/shopspring/decimal v1.3.1 // indirect
        github.com/skeema/knownhosts v1.3.1 // indirect
        github.com/spf13/cast v1.5.0 // indirect
diff --git a/header/action.yml b/header/action.yml
index 54e72b1..ebd50cf 100644
--- a/header/action.yml
+++ b/header/action.yml
@@ -37,7 +37,7 @@ inputs:
     default: ${{ github.token }}
   mode:
     description: |
-      Which mode License Eye should be run in. Choices are `check` or `fix`. 
The
+      Which mode License Eye should be run in. Choices are `check`, `fix` or 
`diff`. The
       default value is `check`.
     required: false
     default: check
diff --git a/pkg/header/diff.go b/pkg/header/diff.go
new file mode 100644
index 0000000..51a91ea
--- /dev/null
+++ b/pkg/header/diff.go
@@ -0,0 +1,152 @@
+// 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 (
+       "fmt"
+       "net/http"
+       "os"
+       "strings"
+       "unicode/utf8"
+
+       lcs "github.com/apache/skywalking-eyes/pkg/license"
+
+       "github.com/sergi/go-diff/diffmatchpatch"
+)
+
+// DiffFile compares the license header of the file with the license configured
+// in the config file, and returns a word-level diff of the two normalized 
texts,
+// the same texts that CheckFile compares, so every difference in the diff is a
+// real cause of the check failure.
+//
+// In the diff, [-text-] marks text that is expected by the configured license
+// but missing in the file, and {+text+} marks text that is in the file but not
+// expected by the configured license. An empty diff is returned when the 
file's
+// license header is valid.
+func DiffFile(file string, config *ConfigHeader) (string, error) {
+       expected := config.NormalizedLicense()
+       if expected == "" {
+               return "", fmt.Errorf("no license content configured (spdx-id 
or content) to diff against")
+       }
+
+       bs, err := os.ReadFile(file)
+       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)
+       }
+
+       content := lcs.NormalizeHeader(string(bs))
+       if satisfy(content, config, expected, config.NormalizedPattern()) {
+               return "", nil
+       }
+
+       if index := strings.Index(content, expected); index >= 0 {
+               return fmt.Sprintf(
+                       "license header is found at normalized offset %d, which 
exceeds the license-location-threshold %d, move it closer to the file start",
+                       index, config.LicenseLocationThreshold,
+               ), nil
+       }
+
+       // Only diff the region of the file where the license header is allowed 
to live,
+       // the content after that region cannot contribute to a successful 
match anyway.
+       end := len(expected) + config.LicenseLocationThreshold
+       if end >= len(content) {
+               end = len(content)
+       } else {
+               for end > 0 && !utf8.RuneStart(content[end]) {
+                       end--
+               }
+       }
+
+       return renderDiff(wordDiff(expected, content[:end])), nil
+}
+
+// wordDiff diffs the two texts word by word, by mapping every word to a "line"
+// and reusing the line-mode diff of diffmatchpatch.
+func wordDiff(expected, actual string) []diffmatchpatch.Diff {
+       dmp := diffmatchpatch.New()
+
+       // The trailing "\n" makes the last word a complete "line" too, so that 
it can
+       // match its occurrences in the middle of the other text.
+       e, a, words := dmp.DiffLinesToChars(
+               strings.ReplaceAll(expected, " ", "\n")+"\n",
+               strings.ReplaceAll(actual, " ", "\n")+"\n",
+       )
+       diffs := dmp.DiffMain(e, a, false)
+
+       return dmp.DiffCharsToLines(diffs, words)
+}
+
+func renderDiff(diffs []diffmatchpatch.Diff) string {
+       const (
+               // contextWords is the number of words to keep on each side 
when a long run of words is collapsed.
+               contextWords = 5
+               // maxEqualRun is the maximum number of words an unchanged run 
can have before being collapsed.
+               maxEqualRun = 12
+               // maxChangedRun is the maximum number of words a changed run 
can have before being collapsed,
+               // it's larger than maxEqualRun because the changed words are 
what the user wants to see.
+               maxChangedRun = 40
+       )
+
+       segments := make([]string, 0, len(diffs))
+       for i, diff := range diffs {
+               words := strings.Fields(diff.Text)
+               if len(words) == 0 {
+                       continue
+               }
+               last := i == len(diffs)-1
+               switch diff.Type {
+               case diffmatchpatch.DiffEqual:
+                       segments = append(segments, collapseWords(words, 
maxEqualRun, contextWords, i == 0, last))
+               case diffmatchpatch.DiffDelete:
+                       segments = append(segments, "[-"+collapseWords(words, 
maxChangedRun, contextWords*2, false, false)+"-]")
+               case diffmatchpatch.DiffInsert:
+                       if last {
+                               // The trailing inserted words are the file 
contents after the license header
+                               // region, which are irrelevant to the diff.
+                               segments = append(segments, "...")
+                               continue
+                       }
+                       segments = append(segments, "{+"+collapseWords(words, 
maxChangedRun, contextWords*2, false, false)+"+}")
+               }
+       }
+
+       return strings.Join(segments, " ")
+}
+
+// collapseWords joins the words with spaces, eliding the middle of runs longer
+// than maxRun. dropHead/dropTail elide one entire side instead, for runs at 
the
+// beginning/end of the diff where only the words next to a change matter.
+func collapseWords(words []string, maxRun, context int, dropHead, dropTail 
bool) string {
+       if len(words) <= maxRun {
+               return strings.Join(words, " ")
+       }
+
+       head := strings.Join(words[:context], " ")
+       tail := strings.Join(words[len(words)-context:], " ")
+       switch {
+       case dropHead:
+               return "... " + tail
+       case dropTail:
+               return head + " ..."
+       default:
+               return head + " ... " + tail
+       }
+}
diff --git a/pkg/header/diff_test.go b/pkg/header/diff_test.go
new file mode 100644
index 0000000..763849d
--- /dev/null
+++ b/pkg/header/diff_test.go
@@ -0,0 +1,121 @@
+// 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 (
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+var diffConfig = &ConfigHeader{
+       License: LicenseConfig{
+               Content: `Apache License 2.0
+  http://www.apache.org/licenses/LICENSE-2.0
+Apache License 2.0`,
+       },
+       LicenseLocationThreshold: 80,
+}
+
+func TestDiffFile(t *testing.T) {
+       tests := []struct {
+               name     string
+               filename string
+               content  string
+               config   *ConfigHeader
+               expected string
+       }{
+               {
+                       name:     "valid header",
+                       filename: "valid.go",
+                       content: `// Apache License 2.0
+//   http://www.apache.org/licenses/LICENSE-2.0
+// Apache License 2.0
+
+package main
+`,
+                       config:   diffConfig,
+                       expected: "",
+               },
+               {
+                       name:     "typo in the header",
+                       filename: "typo.go",
+                       content: `// Apache License 2.0
+//   wwwhttp://www.apache.org/licenses/LICENSE-2.0
+// Apache License 2.0
+
+package main
+`,
+                       config: diffConfig,
+                       expected: "apache license 2.0 " +
+                               "[-http://www.apache.org/licenses/license-2.0-] 
" +
+                               
"{+wwwhttp://www.apache.org/licenses/license-2.0+} " +
+                               "apache license 2.0 ...",
+               },
+               {
+                       name:     "no header at all",
+                       filename: "missing.go",
+                       content: `package main
+
+func main() {}
+`,
+                       config: diffConfig,
+                       expected: "[-apache license 2.0 
http://www.apache.org/licenses/license-2.0 apache license 2.0-] " +
+                               "...",
+               },
+               {
+                       name:     "header too far from the file start",
+                       filename: "far.go",
+                       content: `// aaaa bbbb cccc dddd eeee ffff gggg hhhh
+// Apache License 2.0
+//   http://www.apache.org/licenses/LICENSE-2.0
+// Apache License 2.0
+
+package main
+`,
+                       config: &ConfigHeader{
+                               License:                  diffConfig.License,
+                               LicenseLocationThreshold: 10,
+                       },
+                       expected: "license header is found at normalized offset 
40, " +
+                               "which exceeds the license-location-threshold 
10, " +
+                               "move it closer to the file start",
+               },
+       }
+
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       file := filepath.Join(t.TempDir(), test.filename)
+                       require.NoError(t, os.WriteFile(file, 
[]byte(test.content), 0o600))
+
+                       diff, err := DiffFile(file, test.config)
+                       require.NoError(t, err)
+                       require.Equal(t, test.expected, diff)
+               })
+       }
+}
+
+func TestDiffFileWithoutLicenseContent(t *testing.T) {
+       file := filepath.Join(t.TempDir(), "test.go")
+       require.NoError(t, os.WriteFile(file, []byte("package main\n"), 0o600))
+
+       _, err := DiffFile(file, &ConfigHeader{LicenseLocationThreshold: 80})
+       require.Error(t, err)
+}

Reply via email to