Credits header scan: catch (c)/lowercase/colon forms, second holders, credit-only lines #17

Merged
nibu merged 3 commits from nibu/credits-header-scan into master 2026-11-12 16:57:19 +00:00
2 changed files with 70 additions and 14 deletions

View file

@ -31,6 +31,7 @@ public static class Credits
public const string UnityShaderFile = "Assets/Shaders/Particle Standard Unlit.shader";
public const string AdditiveUiFile = "Assets/Shaders/AdditiveUI.shader";
public const string AcesFile = "Assets/Shaders/CustomDoF/ACES.hlsl";
public const string BezierPathFile = "Assets/Plugins/com.unity.uiextensions/Runtime/Scripts/Utilities/BezierPath.cs";
public const string ClassicNoiseFile = "Assets/Plugins/Celestial Body/Scripts/Shaders/Includes/SimplexNoise2.cginc";
// The first line of the attribution file; the icon credits are the lines after it.
public const string NounProjectHeading = "Noun Project";
@ -138,6 +139,14 @@ public static class Credits
LicenceFile = AcesFile
},
new Credit
{
Name = "Bezier path (UI Extensions)",
Licence = "No named licence; permissive grant in the file header",
Notice = "Author: Herman Tulleken, dev.mag.org.za (http://devmag.org.za/2011/04/05/bzier-curves-a-tutorial/). " +
"Use this code as you wish, at your own risk.",
LicenceFile = BezierPathFile
},
new Credit
{
Name = "ambientCG PBR materials",
Licence = "CC0 1.0 Universal",

View file

@ -37,9 +37,34 @@ namespace Aetheria.Shared.Tests
// Copyright lines found in source headers whose holder no credit shows, each with the reason; keyed like HeaderKeys.
private static readonly System.Collections.Generic.Dictionary<string, string> ReviewedHeaders = new System.Collections.Generic.Dictionary<string, string>
{
["microsoftopen"] = "UniRx files derived from Rx.NET: the header names Microsoft Open Technologies and points at a License.txt that is not in the repo, so no licence is stated; decision owed",
["microsoftopentechnologiesinc"] = "UniRx files derived from Rx.NET: the header names Microsoft Open Technologies and points at a License.txt that is not in the repo, so no licence is stated; decision owed",
};
// Source headers that credit someone ("Credit X", "Based on ...", a source URL) without stating a copyright holder or
// a licence, so there is no true notice to show. Each is the exact line the file carries and the reason it is not a
// credit; the test reads the file, so a line that moves or goes cannot leave a stale entry.
private static readonly (string file, string line, string reason)[] ReviewedAttributionLines =
{
("Assets/Shaders/StableFluids/Fluid.cs", "// https://github.com/keijiro/StableFluids", "names Keijiro's StableFluids as the source; states no holder and no licence"),
("Assets/Shaders/StableFluids/Fluid.shader", "// https://github.com/keijiro/StableFluids", "names Keijiro's StableFluids as the source; states no holder and no licence"),
("Assets/Shaders/StableFluids/Fluid.compute", "// Based on https://github.com/keijiro/StableFluids", "names Keijiro's StableFluids as the source; states no holder and no licence"),
("Assets/Plugins/Celestial Body/Scripts/Shaders/Includes/Triplanar.cginc", "// Based on: medium.com/@bgolus/normal-mapping-for-a-triplanar-shader-10bf39dca05a", "cites bgolus's article for the technique; states no holder and no licence"),
("Assets/Plugins/com.unity.uiextensions/Runtime/Scripts/Primitives/UILineTextureRenderer.cs", "/// Credit jonbro5556", "names a contributor; states no copyright and no licence"),
("Assets/Plugins/com.unity.uiextensions/Runtime/Scripts/Primitives/UILineRenderer.cs", "/// Credit jack.sydorenko, firagon", "names contributors; states no copyright and no licence"),
("Assets/Plugins/com.unity.uiextensions/Runtime/Scripts/Primitives/UILineRendererList.cs", "/// Credit jack.sydorenko, firagon", "names contributors; states no copyright and no licence"),
("Assets/Plugins/com.unity.uiextensions/Runtime/Scripts/Utilities/CableCurve.cs", "/// Credit Farfarer", "names a contributor; states no copyright and no licence"),
("Assets/Plugins/com.unity.uiextensions/Editor/BezierLineRendererEditor.cs", "///Credit Jason Horsburgh", "names a contributor; states no copyright and no licence"),
};
[Fact]
public void ReviewedAttributionLinesAreStillInTheirFiles()
{
Assert.Equal(Array.Empty<string>(), ReviewedAttributionLines
.Where(entry => !File.Exists(Path.Combine(Root(), entry.file)) || !Read(entry.file).Replace("\r", "").Split('\n').Any(line => line.Trim('\uFEFF', ' ', '\t') == entry.line))
.Select(entry => $"{entry.line} in {entry.file}").ToArray());
Assert.All(ReviewedAttributionLines, entry => Assert.False(string.IsNullOrWhiteSpace(entry.reason)));
}
// Bundled works with no licence-like file at all, found by reading Assets. Their licences are not stated in the
// repo, so they are not credited; each path must still exist, so this list cannot go stale unnoticed.
private static readonly string[] UnlicensedWorks =
@ -65,6 +90,7 @@ namespace Aetheria.Shared.Tests
(Credits.AdditiveUiFile, new[] { "Unity Technologies", "Supyrb", "Johannes Deml" }),
(Credits.AcesFile, new[] { "Academy of Motion Picture Arts and Sciences", "A.M.P.A.S." }),
(Credits.ClassicNoiseFile, new[] { "Ashima Arts", "Stefan Gustavson" }),
(Credits.BezierPathFile, new[] { "Herman Tulleken", "Use this code as you wish" }),
};
// Fonts that state holder and licence in their own name tables: the credit's LicenceFile is the licence text shipped
@ -140,12 +166,37 @@ namespace Aetheria.Shared.Tests
private static string[] Unaccounted(string[] found, System.Collections.Generic.IEnumerable<string> credited) =>
found.Where(path => !credited.Contains(path) && !ReviewedNotCredited.ContainsKey(path)).ToArray();
// The holder keys of every "Copyright ..." line in the source files under Assets: the year (when there is one) and
// the first two words of the holder, letters and digits only, lower case. Credits.cs is the credit text itself.
private static readonly Regex HeaderCopyright = new Regex(@"Copyright\s*(?:\([cC]\))?\s*(?:by\s+)?[<\[]?((?:\d{4}|[A-Z])[^\r\n]*)", RegexOptions.CultureInvariant);
// Every copyright marker in a source header: "Copyright", "copyright", "(c)" (not the tail of an identifier), the copyright sign, "Copyright (c)", with an optional
// colon and "by". A line is scanned marker by marker: a holder runs to the next marker or the end of the line, so a
// second holder on the same line is a holder of its own. Credits.cs is the credit text itself, so it is not scanned.
private const string Marker = @"(?:copyright(?:\s*(?:\(c\)|\u00a9))?|(?<!\w)\(c\)|\u00a9)";
private static readonly Regex HeaderCopyright = new Regex(
Marker + @"(?<explicit>\s*:)?\s*(?:by\s+)?[<\[]?(?<holder>.+?)(?=" + Marker + @"|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static string Key(string text) => new string(text.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
// Words that follow "copyright" in licence and header prose and name no holder ("copyright notice", "COPYRIGHT HOLDERS BE
// LIABLE", "copyright laws"); a capitalised word after a bare "Copyright" is a holder unless it is one of these.
private static readonly string[] ProseAfterCopyright =
{ "Notice", "Notices", "Holder", "Holders", "Owner", "Owners", "Law", "Laws", "Act", "Protection", "Statement", "Header", "Information", "Status", "Line", "Lines", "Text", "Licence", "License", "Material", "Works", "Work", "File", "Files" };
// The holders a line of source states: the text after each marker, cut at the end of its sentence, as keys. A holder
// is a year or a capitalised name; "copyright notice" in licence prose names nobody. Without a year the marker must
// be "(c)", the copyright sign or a colon, so "Copyright Notice" prose is not a holder either.
private static System.Collections.Generic.IEnumerable<string> LineHolders(string line)
{
foreach (Match match in HeaderCopyright.Matches(line))
{
var holder = Regex.Replace(match.Groups["holder"].Value.Trim(), @"(\.(\s.*)?|\s*All rights reserved.*)$", "", RegexOptions.IgnoreCase).Trim(' ', '\t', ',', ';', '>', ']');
var marked = Regex.IsMatch(match.Value.Substring(0, match.Groups["holder"].Index - match.Index), @"\(c\)|\u00a9|:", RegexOptions.IgnoreCase);
var year = Regex.IsMatch(holder, @"^\d{4}");
// "Copyright Epic Games, Inc." names a holder with no year or sign; licence prose after the word does not.
var named = holder.Length > 0 && char.IsUpper(holder[0]) && !ProseAfterCopyright.Contains(Regex.Match(holder, @"^\w+").Value, StringComparer.OrdinalIgnoreCase);
if (holder.Length == 0 || !(year || (named && (marked || Regex.IsMatch(match.Value, "^copyright", RegexOptions.IgnoreCase))))) continue;
yield return Key(Normal(holder));
}
}
private static System.Collections.Generic.Dictionary<string, string> HeaderKeys()
{
var root = Root();
@ -154,14 +205,8 @@ namespace Aetheria.Shared.Tests
.Select(path => Path.GetRelativePath(root, path).Replace('\\', '/'))
.Where(path => new[] { ".shader", ".hlsl", ".cginc", ".cs", ".compute" }.Any(ext => path.EndsWith(ext)) && path != "Assets/Scripts/ServerShared/Credits.cs"))
foreach (var line in Read(path).Split('\n'))
foreach (Match match in HeaderCopyright.Matches(line))
{
var words = Regex.Replace(Regex.Replace(match.Groups[1].Value, @"\.\s.*", ""), @"[<>,]", " ").Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(word => word != "by").ToArray();
var year = words.Length > 0 && Regex.IsMatch(words[0], @"^\d{4}") ? 1 : 0;
if (year == 0 && !Regex.IsMatch(line, @"\([cC]\)")) continue;
keys.TryAdd(Key(string.Concat(words.Take(year + 2))), path);
}
foreach (var key in LineHolders(line))
keys.TryAdd(key, path);
return keys;
}
@ -171,10 +216,12 @@ namespace Aetheria.Shared.Tests
public void EveryCopyrightHolderInASourceHeaderIsCreditedOrReviewed()
{
var keys = HeaderKeys();
Assert.Contains("2015academyof", keys.Keys);
Assert.Contains(Key("2015 Academy of Motion Picture Arts and Sciences (A.M.P.A.S"), keys.Keys);
Assert.Contains("2016unitytechnologies", keys.Keys);
Assert.Contains("2017supyrb", keys.Keys);
var shown = Key(string.Join(" ", Load().Select(credit => Normal(credit.Notice))));
// The whole holder must equal a holder some credit's notice states, read by the same extractor: a bare year, a short
// name, or a prefix of a credited name is a different key and fails.
var shown = Load().SelectMany(credit => credit.Notice.Split('\n')).SelectMany(LineHolders).ToHashSet();
Assert.Equal(Array.Empty<string>(), keys.Where(k => !shown.Contains(k.Key) && !ReviewedHeaders.ContainsKey(k.Key)).Select(k => $"{k.Key} in {k.Value}").ToArray());
Assert.Equal(Array.Empty<string>(), ReviewedHeaders.Keys.Where(k => !keys.ContainsKey(k)).ToArray());
}