Kiểm kê giấy phép phần mềm: đọc biểu thức SPDX và ra quyết định theo cách dùng
Câu hỏi bài này trả lời: cột giấy phép của một bảng kiểm kê phần mềm thường chứa chuỗi như
MIT OR GPL-3.0-only. Chuỗi đó nghĩa chính xác là gì, làm sao đổi nó thành quyết định “cho phép, cần xem hay chặn” tùy cách bạn dùng phần mềm, và việc nào máy làm được, việc nào vẫn là của người?
Cần biết trước: Python cơ bản và CSV; không cần kiến thức pháp lý. Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64 (code dùng X | None và zip(strict=True) nên cần Python 3.10 trở lên, nhưng bài chỉ chạy thật trên 3.14.4). Bước cuối chỉ đọc danh sách Homebrew 7.0.7 khi máy có brew và chỉ in số tổng hợp. Chưa chạy trên Linux. Bài không phải tư vấn pháp lý: bảng nhóm và quyết định bên dưới là ví dụ để minh họa cách mã hóa một chính sách, không phải kết luận về nghĩa vụ của giấy phép nào.
Một cột, ba việc khác nhau
| Việc | Câu hỏi | Ai trả lời |
|---|---|---|
| Nhận diện | Chuỗi viết đúng chưa, mỗi mã là giấy phép nào? | SPDX: danh sách mã và cú pháp biểu thức |
| Phân loại | Mỗi mã thuộc nhóm nào trong chính sách của tôi? | Bảng chính sách do tổ chức viết và bảo trì |
| Quyết định | Với cách tôi dùng phần mềm này, cho phép hay không? | Chính sách cộng kịch bản dùng cộng người có thẩm quyền |
SPDX License List nêu mục đích của nó là “enable efficient and reliable identification” của giấy phép và ngoại lệ, tức việc nhận diện. Bảng chính của danh sách có các cột tên đầy đủ, mã, “FSF Free/Libre?” và “OSI Approved?”; không có cột nhóm rủi ro. Bài đọc danh sách ngày 2026-10-04 ở bản 3.29.0 (2026-09-16). Hai việc sau là chính sách của bạn: bài làm việc thứ nhất và thứ hai bằng code chạy được, rồi chỉ ra chỗ việc thứ ba không giao cho máy được.
Biểu thức SPDX đọc thế nào
Đặc tả SPDX 2.3 (phụ lục D, “SPDX License Expressions”) định nghĩa cú pháp. Những điều bài dựa vào, đối chiếu với trang đặc tả đọc ngày 2026-10-04:
- Thứ tự áp dụng mặc định là
+ WITH AND OR, toán tử đứng trước áp dụng trước. Ví dụ của chính đặc tả:LGPL-2.1-only OR BSD-3-Clause AND MITlà lựa chọn giữaLGPL-2.1-onlyvà biểu thứcBSD-3-Clause AND MIT, vìANDưu tiên hơnOR. Ngoặc đổi thứ tự. ORlà chọn,ANDlà cùng lúc. Đặc tả dùngORkhi “given a choice between” các giấy phép vàANDkhi “required to simultaneously comply with two or more licenses”. Cả hai giao hoán.- Chữ hoa chữ thường: toán tử
AND,OR,WITHnên so khớp phân biệt hoa thường; mã giấy phép (kể cả mã ngoại lệ) nên so khớp không phân biệt hoa thường, nênMIT,MitvàmItlà cùng một mã. - Dấu
+sau mã nghĩa là bản hiện tại hoặc bất kỳ bản mới hơn (ví dụCDDL-1.0+), và không được có khoảng trắng giữa mã và dấu+. WITH: vế trái là một giấy phép đơn, vế phải là mã ngoại lệ, ví dụGPL-2.0-or-later WITH Bison-exception-2.2; phải có khoảng trắng hai bên.- Khoảng trắng hoặc ngoặc phải có hai bên
ANDvàOR; mã chỉ gồm chữ, số,-và.; trong định dạng tag:value, biểu thức nằm trên một dòng. LicenseRef-…(có thể kèmDocumentRef-…:đứng trước) là giấy phép tự đặt, không nằm trong danh sách.
Lab: bộ đọc biểu thức
Tạo thư mục trống rồi lưu spdx.py. Bộ đọc là đệ quy xuống, mỗi mức ưu tiên một hàm: expression xử lý OR và gọi conjunction xử lý AND, hàm này gọi restricted xử lý WITH, hàm cuối gọi primary (ngoặc hoặc một mã). Mỗi hàm chỉ biết một toán tử nên thứ tự ưu tiên nằm trong cấu trúc lời gọi, không cần bảng độ ưu tiên. tokenize quét một lượt bằng một regex neo tại vị trí hiện tại, nên ký tự lạ báo lỗi kèm vị trí thay vì bị bỏ qua. show in cây dạng tiền tố; render in lại dạng SPDX với ngoặc tường minh cho mọi biểu thức lồng nhau, để thấy bộ đọc đã hiểu thế nào. Bộ đọc chỉ kiểm cú pháp, không kiểm mã có nằm trong danh sách SPDX hay không.
import re
from dataclasses import dataclass
class SpdxError(ValueError):
"""Chuỗi không phải biểu thức SPDX hợp lệ."""
@dataclass(frozen=True)
class Id:
name: str
later: bool = False # hậu tố "+": bản này hoặc bản mới hơn
@dataclass(frozen=True)
class With:
base: Id
exception: str
@dataclass(frozen=True)
class And:
items: tuple
@dataclass(frozen=True)
class Or:
items: tuple
TOKEN = re.compile(r"\s*(\(|\)|[A-Za-z0-9.:-]+\+?)")
REF = re.compile(r"(?:DocumentRef-[A-Za-z0-9.-]+:)?LicenseRef-[A-Za-z0-9.-]+", re.IGNORECASE)
OPERATORS = ("AND", "OR", "WITH")
def tokenize(text: str) -> list[str]:
if "\n" in text or "\r" in text:
raise SpdxError("biểu thức phải nằm trên một dòng")
tokens, pos = [], 0
while match := TOKEN.match(text, pos):
tokens.append(match.group(1))
pos = match.end()
rest = text[pos:]
if rest.strip():
bad = pos + len(rest) - len(rest.lstrip())
raise SpdxError(f"ký tự lạ {text[bad]!r} ở vị trí {bad}")
if not tokens:
raise SpdxError("biểu thức rỗng")
return tokens
def license_id(token: str) -> Id:
later = token.endswith("+")
name = token[:-1] if later else token
if ":" in name or name.lower().startswith(("licenseref-", "documentref-")):
if later:
raise SpdxError(f"{name}: dấu + không dùng với LicenseRef")
if not REF.fullmatch(name):
raise SpdxError(f"{name}: không đúng dạng [DocumentRef-x:]LicenseRef-y")
return Id(name, later)
class Parser:
"""OR thấp nhất, rồi AND, rồi WITH, rồi dấu + (đặc tả SPDX); toán tử phân biệt hoa thường."""
def __init__(self, tokens: list[str]) -> None:
self.tokens, self.pos = tokens, 0
def peek(self) -> str | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def take(self) -> str | None:
token = self.peek()
self.pos += 1
return token
def expression(self):
items = [self.conjunction()]
while self.peek() == "OR":
self.take()
items.append(self.conjunction())
return items[0] if len(items) == 1 else Or(tuple(items))
def conjunction(self):
items = [self.restricted()]
while self.peek() == "AND":
self.take()
items.append(self.restricted())
return items[0] if len(items) == 1 else And(tuple(items))
def restricted(self):
grouped = self.peek() == "("
node = self.primary()
if self.peek() != "WITH":
return node
if grouped:
raise SpdxError("vế trái của WITH phải là một giấy phép, không phải biểu thức trong ngoặc")
self.take()
exception = self.take()
if exception is None or exception in (*OPERATORS, "(", ")") or exception.endswith("+"):
raise SpdxError("sau WITH phải là mã ngoại lệ, không có dấu +")
return With(node, exception)
def primary(self):
token = self.take()
if token == "(":
node = self.expression()
if self.take() != ")":
raise SpdxError("thiếu dấu ngoặc đóng")
return node
if token is None or token == ")" or token in OPERATORS:
raise SpdxError(f"cần một giấy phép, gặp {'hết chuỗi' if token is None else repr(token)}")
return license_id(token)
def parse(text: str):
parser = Parser(tokenize(text))
node = parser.expression()
left = parser.peek()
if left is not None:
hint = " (toán tử SPDX phải viết hoa: AND, OR, WITH)" if left.upper() in OPERATORS else ""
raise SpdxError(f"thừa {left!r}{hint}")
return node
def name_of(node: Id) -> str:
return node.name + ("+" if node.later else "")
def show(node) -> str:
"""Dạng tiền tố, để thấy đúng cây: OR(MIT, AND(A, B))."""
if isinstance(node, Id):
return name_of(node)
if isinstance(node, With):
return f"WITH({name_of(node.base)}, {node.exception})"
return f"{type(node).__name__.upper()}({', '.join(show(item) for item in node.items)})"
def render(node, top: bool = True) -> str:
"""Dạng SPDX với ngoặc tường minh cho mọi biểu thức lồng nhau."""
if isinstance(node, (And, Or)):
text = (" AND " if isinstance(node, And) else " OR ").join(render(item, False) for item in node.items)
return text if top else f"({text})"
if isinstance(node, With):
return f"{name_of(node.base)} WITH {node.exception}"
return name_of(node)
Lab: từ cây đến nhóm rủi ro
Lưu policy.py. FAMILIES xếp năm nhóm từ dễ đến khó; thứ hạng trong danh sách là thứ được so sánh:
ANDlấy nhóm khó nhất của các vế, vì phải tuân thủ cả hai.ORlấy nhánh dễ nhất và ghi lại nhánh đã chọn (choices), vì nghĩa vụ chỉ áp dụng cho giấy phép bạn chọn; hai nhánh ngang hạng thì giữ nhánh đứng trước.WITHgiữ nhóm của giấy phép gốc, trừ ngoại lệ liên kết có trong chính sách:Classpath-exception-2.0hạstrong-copyleftxuốngweak-copyleft. Đó là lựa chọn của chính sách mẫu. Văn bản ngoại lệ ghi “As a special exception, the copyright holders of this library give you permission to link this library with independent modules”, và nhóm nào hợp với văn bản đó là việc của người đọc nó. Ngoại lệ chưa có trong chính sách chỉ gắn cờexception, nhóm giữ nguyên.- Mã lạ,
LicenseRef-…và chuỗi sai cú pháp thànhunknown, bị chặn ở mọi kịch bản.ANDvới một mã lạ chounknown;ORvới một mã lạ vẫn chọn được nhánh đã biết. - Mã cũ của GNU (
GPL-2.0,GPL-2.0+, …) được đọc như bản-onlyhoặc-or-latertương ứng và gắn cờdeprecated; phần sau nói rõ căn cứ. Dấu+trên mã hiện hành gắn cờlater.
import re
from dataclasses import dataclass, field
from spdx import And, Id, With, parse, render
FAMILIES = ("permissive", "weak-copyleft", "strong-copyleft", "network-copyleft", "unknown")
# Chính sách mẫu của bài, không phải phân loại của SPDX: thay bằng chính sách của tổ chức bạn.
POLICY = {
"permissive": "MIT MIT-0 BSD-2-Clause BSD-3-Clause Apache-2.0 ISC Zlib Unlicense CC0-1.0 0BSD",
"weak-copyleft": "LGPL-2.1-only LGPL-2.1-or-later LGPL-3.0-only LGPL-3.0-or-later MPL-2.0 EPL-2.0",
"strong-copyleft": "GPL-2.0-only GPL-2.0-or-later GPL-3.0-only GPL-3.0-or-later",
"network-copyleft": "AGPL-3.0-only AGPL-3.0-or-later",
}
FAMILY_OF = {name.lower(): family for family, names in POLICY.items() for name in names.split()}
LINKING_EXCEPTIONS = {"classpath-exception-2.0"}
GNU_OLD = re.compile(r"(?:GPL|LGPL|AGPL)-\d\.\d", re.IGNORECASE)
ACTIONS = {
"internal": dict(zip(FAMILIES, ("allow", "allow", "allow", "review", "block"))),
"distribute": dict(zip(FAMILIES, ("allow", "review", "review", "review", "block"))),
}
@dataclass
class Verdict:
family: str
choices: list = field(default_factory=list) # nhánh OR đã chọn
flags: list = field(default_factory=list) # (loại, nội dung)
@property
def rank(self) -> int:
return FAMILIES.index(self.family)
def lookup(name: str, flags: list | None = None) -> Verdict:
family = FAMILY_OF.get(name.lower())
if family:
return Verdict(family, flags=flags or [])
return Verdict("unknown", flags=[*(flags or []), ("unknown", f"{name} chưa có trong chính sách")])
def evaluate(node) -> Verdict:
if isinstance(node, Id):
if GNU_OLD.fullmatch(node.name):
new = node.name + ("-or-later" if node.later else "-only")
old = node.name + ("+" if node.later else "")
return lookup(new, [("deprecated", f"{old} là id cũ, dùng {new}")])
verdict = lookup(node.name)
if node.later:
note = f"{node.name}+ chấp nhận cả bản mới hơn: nghĩa vụ có thể đổi theo bản"
verdict.flags.append(("later", note))
return verdict
if isinstance(node, With):
verdict = evaluate(node.base)
if node.exception.lower() in LINKING_EXCEPTIONS:
if verdict.family == "strong-copyleft":
verdict.family = "weak-copyleft"
note = f"WITH {node.exception}: hạ xuống weak-copyleft theo chính sách mẫu"
verdict.flags.append(("downgrade", note))
else:
verdict.flags.append(("exception", f"WITH {node.exception} chưa có trong chính sách"))
return verdict
parts = [evaluate(item) for item in node.items]
if isinstance(node, And):
worst = max(parts, key=lambda part: part.rank)
choices = [choice for part in parts for choice in part.choices]
flags = [flag for part in parts for flag in part.flags]
return Verdict(worst.family, choices, flags)
best = min(range(len(parts)), key=lambda i: parts[i].rank)
old_ids = [f for i, part in enumerate(parts) if i != best for f in part.flags if f[0] == "deprecated"]
choices = [render(node.items[best]), *parts[best].choices]
return Verdict(parts[best].family, choices, [*parts[best].flags, *old_ids])
def decide(verdict: Verdict, scenario: str) -> str:
return ACTIONS[scenario][verdict.family]
def assess(text: str) -> Verdict:
return evaluate(parse(text))
Bảng chính sách mẫu trong code, đọc theo hàng:
| Nhóm | Mã trong chính sách mẫu | Nội bộ | Phân phối |
|---|---|---|---|
permissive | MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, Zlib, Unlicense, CC0-1.0, 0BSD | allow | allow |
weak-copyleft | LGPL 2.1 và 3.0 (cả -only và -or-later), MPL-2.0, EPL-2.0 | allow | review |
strong-copyleft | GPL 2.0 và 3.0 (cả -only và -or-later) | allow | review |
network-copyleft | AGPL-3.0 (cả -only và -or-later) | review | review |
unknown | mọi mã khác, LicenseRef-…, chuỗi sai cú pháp | block | block |
Cả 22 mã trong bảng là mã hiện hành của SPDX License List 3.29.0 (kiểm một lần ngày 2026-10-04 bằng cách đọc HTML của trang, ngoài lab). Hai cột kịch bản là hai cách dùng khác nhau của cùng một phần mềm, và căn cứ của chúng đọc từ GNU GPL FAQ ngày 2026-10-04:
- Nội bộ. Mục
#GPLRequireSourcePostedPublic: “The GPL does not require you to release your modified version, or any part of it”, và “an organization can make a modified version and use it internally without ever releasing it outside the organization”. Mục#InternalDistributionnói làm và dùng nhiều bản sao trong một tổ chức không phải phân phối. Vì vậystrong-copyleftlàallowở cột nội bộ. - Ranh giới của “nội bộ” hẹp hơn người ta nghĩ. Cũng ở
#InternalDistribution: “providing copies to contractors for use off-site is distribution”. Quyết định gắn với kịch bản thật, không với loại giấy phép: chuyển bản cho nhà thầu làm việc bên ngoài là chuyển cả phần mềm sang cột “phân phối”. - Phân phối. Mục
#LinkingWithGPL: liên kết với một chương trình GPL nghĩa là “you must release your program under a license compatible with the GPL”. Bài xếp nhóm này vàoreview. - AGPL. Mục
#UnreleasedModsAGPL: AGPL “requires that modified versions of the software offer all users interacting with it over a computer network an opportunity to receive the source”. Nênnetwork-copyleftlàreviewcả ở cột nội bộ, khi dịch vụ có người dùng truy cập qua mạng. - Nhóm trung gian và
permissive. Bài không đọc văn bản LGPL, MPL hay EPL nên không mô tả nghĩa vụ của chúng;weak-copyleftlàreviewở cột phân phối chỉ vì chính sách mẫu chọn thế. Với MIT, văn bản trên SPDX có điều kiện “The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software”, nênallowở đây chưa bao gồm việc giữ thông báo đó.
Mã cũ của GNU
Trang SPDX License List ghi: “Release 3.0 replaced previous Identifiers for GNU licenses with more explicit Identifiers to reflect the ‘this version only’ or ‘any later version’ option specific to those licenses.” Bảng “Deprecated License Identifiers” của trang có ba cột (tên đầy đủ, mã, “Deprecated as of:”) và không có cột mã thay thế. Sáu hàng bài dùng:
| Mã cũ | Tên đầy đủ trong bảng | Deprecated as of | Bài đọc thành |
|---|---|---|---|
GPL-2.0 | GNU General Public License v2.0 only | 3.0 | GPL-2.0-only |
GPL-2.0+ | GNU General Public License v2.0 or later | 2.0rc2 | GPL-2.0-or-later |
GPL-3.0 | GNU General Public License v3.0 only | 3.0 | GPL-3.0-only |
GPL-3.0+ | GNU General Public License v3.0 or later | 2.0rc2 | GPL-3.0-or-later |
LGPL-2.1 | GNU Lesser General Public License v2.1 only | 3.0 | LGPL-2.1-only |
AGPL-3.0 | GNU Affero General Public License v3.0 | 3.0 | AGPL-3.0-only |
Cột cuối là suy luận từ tên đầy đủ (“only”, “or later”) và từ câu của trang, không phải dữ liệu của bảng; bài kiểm cả sáu mã thay thế có mặt trong danh sách hiện hành. Biểu thức GNU_OLD trong code tổng quát hóa sáu hàng này cho GPL, LGPL và AGPL với số phiên bản dạng N.M; mã cũ khác (như AGPL-1.0, deprecated từ 3.1) cần đọc bảng. Bảng còn có các mã gộp ngoại lệ vào tên, như GPL-2.0-with-classpath-exception (deprecated từ 2.0rc2), và trang ghi cách dùng đúng là “the License Expression syntax as of v2.0”. Bộ đọc coi mã gộp là mã lạ, nên chúng rơi vào unknown và đến tay người.
Lab: kiểm thử
Lưu test_license.py rồi chạy. Test dùng bảng dữ liệu để ghim những điều bên trên bằng ví dụ cụ thể: cây của từng cặp toán tử và các dạng khoảng trắng; các ví dụ của chính đặc tả khứ hồi qua render; toán tử chữ thường bị từ chối; 22 chuỗi sai cú pháp bị từ chối; nhóm, nhánh OR đã chọn và loại cờ của 25 biểu thức (gồm AND lấy nhóm khó nhất, OR lấy nhánh dễ nhất, ngoại lệ liên kết chỉ hạ strong-copyleft, và ba tên kiểu Debian: BSD-3-clause khớp mã SPDX không phân biệt hoa thường, còn Expat và GPL-2+ thành unknown); sáu mã cũ đọc thành đúng nhóm của mã thay thế; và bảng quyết định theo kịch bản.
import unittest
from policy import ACTIONS, FAMILIES, Verdict, assess, decide
from spdx import SpdxError, parse, render, show
TREES = {
"A OR B AND C": "OR(A, AND(B, C))",
"A AND B OR C": "OR(AND(A, B), C)",
"A AND B WITH E": "AND(A, WITH(B, E))",
"A+ WITH E": "WITH(A+, E)",
"A+ OR B": "OR(A+, B)",
"(A OR B) AND C": "AND(OR(A, B), C)",
"A OR B OR C": "OR(A, B, C)",
"A AND (B AND C)": "AND(A, AND(B, C))",
"MIT AND(Apache-2.0)": "AND(MIT, Apache-2.0)",
" MIT OR \t Apache-2.0 ": "OR(MIT, Apache-2.0)",
"MITORApache-2.0": "MITORApache-2.0",
}
SPEC_EXAMPLES = [
"LGPL-2.1-only OR MIT",
"LGPL-2.1-only AND MIT",
"GPL-2.0-or-later WITH Bison-exception-2.2",
"LGPL-2.1-only OR BSD-3-Clause AND MIT",
"MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)",
"CDDL-1.0+",
"LicenseRef-23",
"LicenseRef-MIT-Style-1",
"DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2",
]
INVALID = [
"", " ", "()", "MIT OR", "OR MIT", "MIT AND OR Apache-2.0", "(MIT", "MIT)", "MIT Apache-2.0",
"MIT WITH", "MIT WITH Foo+", "(MIT) WITH Foo", "MIT WITH A WITH B", "MIT WITH AND",
"MIT/Apache-2.0", "MIT, Apache-2.0", "A++", "MIT +", "MIT OR\nApache-2.0",
"LicenseRef-", "LicenseRef-x+", "DocumentRef-x:MIT",
] # fmt: skip
# biểu thức: (nhóm, nhánh OR đã chọn, loại cờ)
VERDICTS = {
"MIT": ("permissive", [], []),
"mit": ("permissive", [], []),
"MPL-2.0": ("weak-copyleft", [], []),
"GPL-3.0-only": ("strong-copyleft", [], []),
"AGPL-3.0-or-later": ("network-copyleft", [], []),
"Foo-1.0": ("unknown", [], ["unknown"]),
"LicenseRef-vendor": ("unknown", [], ["unknown"]),
"MIT AND GPL-3.0-only": ("strong-copyleft", [], []),
"MIT AND Foo-1.0": ("unknown", [], ["unknown"]),
"MIT AND AGPL-3.0-only AND GPL-3.0-only": ("network-copyleft", [], []),
"MIT OR GPL-3.0-only": ("permissive", ["MIT"], []),
"Apache-2.0 OR MIT": ("permissive", ["Apache-2.0"], []),
"GPL-3.0-only OR AGPL-3.0-only": ("strong-copyleft", ["GPL-3.0-only"], []),
"Foo-1.0 OR GPL-3.0-only": ("strong-copyleft", ["GPL-3.0-only"], []),
"GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)": ("strong-copyleft", ["MIT"], []),
"MIT OR GPL-2.0": ("permissive", ["MIT"], ["deprecated"]),
"Apache-2.0+": ("permissive", [], ["later"]),
"GPL-2.0-only WITH Classpath-exception-2.0": ("weak-copyleft", [], ["downgrade"]),
"AGPL-3.0-only WITH Classpath-exception-2.0": ("network-copyleft", [], []),
"MIT WITH Classpath-exception-2.0": ("permissive", [], []),
"GPL-2.0-or-later WITH Bison-exception-2.2": ("strong-copyleft", [], ["exception"]),
"Foo-1.0 WITH Classpath-exception-2.0": ("unknown", [], ["unknown"]),
"BSD-3-clause": ("permissive", [], []),
"Expat": ("unknown", [], ["unknown"]),
"GPL-2+": ("unknown", [], ["unknown", "later"]),
}
DEPRECATED = {
"GPL-2.0": "GPL-2.0-only",
"GPL-2.0+": "GPL-2.0-or-later",
"GPL-3.0": "GPL-3.0-only",
"GPL-3.0+": "GPL-3.0-or-later",
"LGPL-2.1": "LGPL-2.1-only",
"AGPL-3.0": "AGPL-3.0-only",
}
class TreeTests(unittest.TestCase):
def test_trees_follow_plus_with_and_or(self):
for text, tree in TREES.items():
with self.subTest(text):
self.assertEqual(show(parse(text)), tree)
def test_spec_examples_round_trip_and_render_shows_precedence(self):
for text in SPEC_EXAMPLES:
with self.subTest(text):
node = parse(text)
self.assertEqual(parse(render(node)), node)
node = parse("LGPL-2.1-only OR BSD-3-Clause AND MIT")
self.assertEqual(render(node), "LGPL-2.1-only OR (BSD-3-Clause AND MIT)")
def test_operators_are_case_sensitive(self):
for text in ("MIT or Apache-2.0", "MIT And Apache-2.0", "GPL-2.0-only with Classpath-exception-2.0"):
with self.subTest(text), self.assertRaises(SpdxError):
parse(text)
def test_invalid_strings_are_rejected(self):
for text in INVALID:
with self.subTest(text), self.assertRaises(SpdxError):
parse(text)
class PolicyTests(unittest.TestCase):
def test_verdicts(self):
for text, expected in VERDICTS.items():
with self.subTest(text):
verdict = assess(text)
actual = (verdict.family, verdict.choices, [kind for kind, _ in verdict.flags])
self.assertEqual(actual, expected)
def test_deprecated_gnu_ids_read_as_their_explicit_replacement(self):
for old, new in DEPRECATED.items():
with self.subTest(old):
verdict = assess(old)
self.assertEqual(verdict.family, assess(new).family)
self.assertEqual(len(verdict.flags), 1)
self.assertIn(new, verdict.flags[0][1])
def test_or_is_commutative_for_the_family(self):
for left, right in (("MIT", "GPL-3.0-only"), ("Apache-2.0", "MIT"), ("Foo-1.0", "MPL-2.0")):
with self.subTest(left=left, right=right):
self.assertEqual(assess(f"{left} OR {right}").family, assess(f"{right} OR {left}").family)
def test_decisions_per_scenario(self):
expected = {
"internal": ["allow", "allow", "allow", "review", "block"],
"distribute": ["allow", "review", "review", "review", "block"],
}
self.assertEqual(set(ACTIONS), set(expected))
for scenario, actions in expected.items():
for family, action in zip(FAMILIES, actions, strict=True):
with self.subTest(scenario=scenario, family=family):
self.assertEqual(decide(Verdict(family), scenario), action)
if __name__ == "__main__":
unittest.main()
python3 -B test_license.py 2>&1
Đọc kết quả: 8 test đạt. Tác giả đã cố ý phá năm chỗ trong code trên (đảo thứ tự AND và OR, nhận toán tử chữ thường, cho AND lấy nhóm dễ nhất, bỏ cờ mã cũ ở nhánh không chọn, hạ nhóm cho mọi nhóm khó hơn weak-copyleft khi gặp ngoại lệ liên kết) và thấy ít nhất một test đỏ ở mỗi lần phá; các lần phá không nằm trong lab, chỉ để kiểm test không rỗng.
Lab: cây và bảng chính sách
Lưu demo.py. Phần đầu in ba dạng của bốn biểu thức (chuỗi gốc, dạng có ngoặc tường minh và cây tiền tố); phần hai chạy mười một biểu thức qua chính sách ở cả hai kịch bản.
from policy import assess, decide
from spdx import parse, render, show
print("== Cây của biểu thức")
for text in (
"LGPL-2.1-only OR BSD-3-Clause AND MIT",
"MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)",
"GPL-2.0-or-later WITH Bison-exception-2.2",
"CDDL-1.0+ OR MIT AND Zlib",
):
node = parse(text)
print(f"{text}\n {render(node)}\n {show(node)}")
print("== Chính sách mẫu")
print(f"{'biểu thức':50} {'nhóm':17} {'nội bộ':7} {'phân phối':9} nhánh đã chọn")
for text in (
"MIT",
"MIT OR GPL-3.0-only",
"MIT AND GPL-3.0-only",
"GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)",
"GPL-2.0-only WITH Classpath-exception-2.0",
"GPL-2.0-or-later WITH Bison-exception-2.2",
"AGPL-3.0-only",
"GPL-2.0+",
"Foo-1.0",
"MIT OR Foo-1.0",
"MIT AND Foo-1.0",
):
verdict = assess(text)
kinds = ",".join(sorted({kind for kind, _ in verdict.flags})) or "-"
print(
f"{text:50} {verdict.family:17} {decide(verdict, 'internal'):7} {decide(verdict, 'distribute'):9} "
f"{', '.join(verdict.choices) or '-'} [{kinds}]"
)
python3 -B demo.py
== Cây của biểu thức
LGPL-2.1-only OR BSD-3-Clause AND MIT
LGPL-2.1-only OR (BSD-3-Clause AND MIT)
OR(LGPL-2.1-only, AND(BSD-3-Clause, MIT))
MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)
MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)
AND(MIT, OR(LGPL-2.1-or-later, BSD-3-Clause))
GPL-2.0-or-later WITH Bison-exception-2.2
GPL-2.0-or-later WITH Bison-exception-2.2
WITH(GPL-2.0-or-later, Bison-exception-2.2)
CDDL-1.0+ OR MIT AND Zlib
CDDL-1.0+ OR (MIT AND Zlib)
OR(CDDL-1.0+, AND(MIT, Zlib))
== Chính sách mẫu
biểu thức nhóm nội bộ phân phối nhánh đã chọn
MIT permissive allow allow - [-]
MIT OR GPL-3.0-only permissive allow allow MIT [-]
MIT AND GPL-3.0-only strong-copyleft allow review - [-]
GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT) strong-copyleft allow review MIT [-]
GPL-2.0-only WITH Classpath-exception-2.0 weak-copyleft allow review - [downgrade]
GPL-2.0-or-later WITH Bison-exception-2.2 strong-copyleft allow review - [exception]
AGPL-3.0-only network-copyleft review review - [-]
GPL-2.0+ strong-copyleft allow review - [deprecated]
Foo-1.0 unknown block block - [unknown]
MIT OR Foo-1.0 permissive allow allow MIT [-]
MIT AND Foo-1.0 unknown block block - [unknown]
Đọc kết quả:
- Đọc từ trái sang phải sẽ sai.
LGPL-2.1-only OR BSD-3-Clause AND MITthànhLGPL-2.1-only OR (BSD-3-Clause AND MIT): chọnLGPL-2.1-onlylà đủ. Đọc(LGPL-2.1-only OR BSD-3-Clause) AND MITthìMITluôn bắt buộc, một bộ nghĩa vụ khác.renderđặt ngoặc để người đọc thấy cách hiểu của máy.CDDL-1.0+ OR MIT AND Zlibcho thấy+dính vào mã, không phải toán tử đứng riêng. ANDvàORkhác nhau ở chỗ có lựa chọn.MIT AND GPL-3.0-onlylàstrong-copyleft;MIT OR GPL-3.0-onlylàpermissivevới nhánhMITđược ghi lại. TrongGPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT), chọnMITở vế ngoặc không hạ được nhóm, vì vế GPL là bắt buộc.- Ngoại lệ không tự động nhẹ hơn.
Classpath-exception-2.0có trong chính sách nênGPL-2.0-only WITH Classpath-exception-2.0xuốngweak-copyleft;Bison-exception-2.2chưa có trong chính sách nênGPL-2.0-or-later WITH Bison-exception-2.2giữstrong-copyleftvà chỉ gắn cờexceptionđể người quyết định có thêm ngoại lệ đó vào chính sách hay không. - Cùng một chuỗi, hai quyết định.
strong-copyleftlàallowở cột nội bộ vàreviewở cột phân phối;network-copyleftlàreviewở cả hai;unknownbị chặn ở cả hai.MIT OR Foo-1.0qua được vì nhánhMITđủ, cònMIT AND Foo-1.0bị chặn vì phải tuân thủ cảFoo-1.0.
Hai nơi khác cũng ghi giấy phép: Debian và Homebrew
Debian định nghĩa định dạng tệp copyright đọc được bằng máy (bản 1.0 của định dạng; trang đề ngày 2026-03-31). Trang ghi rằng định dạng này và SPDX “attempt to be somewhat compatible. However, the two formats have different aims, and so the formats are different.”
| Điểm | SPDX 2.3 | Debian copyright-format 1.0 |
|---|---|---|
| Toán tử | AND, OR, WITH viết hoa; nên so khớp phân biệt hoa thường | and, or (ví dụ của trang viết thường); tên giấy phép không phân biệt hoa thường |
| Ưu tiên | + WITH AND OR; dùng ngoặc để đổi | “and” ưu tiên hơn “or” trừ khi đứng sau dấu phẩy: A or B and C là A or (B and C), còn A or B, and C là (A or B) and C |
| Ngoại lệ | WITH cộng mã trong danh sách ngoại lệ | chữ tự do: thêm with từ-khóa exception vào tên ngắn |
Hậu tố + | bản này hoặc mới hơn, dính liền vào mã | cùng ý, dính liền vào tên ngắn (GPL-1+); tên GPL trơn là bản 1 |
| Tên | mã trong danh sách SPDX hoặc LicenseRef-… | tên ngắn; trang khuyên dùng Expat thay cho MIT khi khớp |
Hệ quả cho code: chuỗi Debian đưa thẳng vào bộ đọc SPDX bị từ chối ở toán tử chữ thường (test ghim MIT or Apache-2.0), và dù đổi toán tử sang chữ hoa thì tên như GPL-2, GPL-2+ hay Expat không nằm trong SPDX License List 3.29.0 (đã kiểm ngày 2026-10-04), nên chính sách xếp chúng vào unknown và chặn thay vì đoán. Đổi từ Debian sang SPDX cần một bảng ánh xạ tên có kiểm và người đọc các ca có dấu phẩy hoặc with … exception. Bài không làm bộ đổi vì chưa chạy trên Linux.
Homebrew ghi giấy phép trong trường license của formula. Formula Cookbook hướng dẫn dùng mã trong SPDX License List, license :public_domain cho phần mềm thuộc phạm vi công cộng, và :any_of, :all_of, :with cho biểu thức phức tạp: :any_of “should be used when the user can choose which licence to use”, :all_of khi “the user must use all licences”, :with để chỉ định “a valid SPDX exception”, và thêm + vào mã để chỉ các bản mới hơn của cùng giấy phép. Ba từ khóa ứng với OR, AND, WITH của SPDX. Bước Homebrew bên dưới đo xem JSON của brew info có đúng là chuỗi biểu thức SPDX không.
Lab: kiểm kê tổng hợp
Bảng kiểm kê giả gồm 23 gói tự đặt tên (không phải phần mềm thật, giấy phép cũng không phải của phần mềm nào) với đủ các dạng chuỗi: mã đơn, OR, AND, WITH, mã cũ, mã lạ, LicenseRef-…, dấu + và ba chuỗi sai cú pháp (toán tử chữ thường, ô trống, dấu /). Lưu inventory.csv và report.py. Báo cáo đếm theo nhóm và theo quyết định ở cả hai kịch bản, liệt kê gói bị chặn kèm lý do, ghi lại nhánh OR đã chọn, nêu việc cần sửa metadata, so với cách tìm chuỗi GPL, và thoát mã 1 khi còn gói bị chặn ở kịch bản phân phối: dạng cổng dùng được trong CI.
package,version,license
alpha-lib,1.4.2,MIT
beta-cli,0.9.0,Apache-2.0
gamma-codec,2.1.0,BSD-3-Clause
delta-db,5.0.1,GPL-3.0-or-later
epsilon-ui,3.2.0,MIT OR GPL-3.0-only
zeta-net,1.0.0,LGPL-2.1-or-later AND MIT
eta-json,4.4.0,Apache-2.0 OR MIT
theta-sync,2.0.0,AGPL-3.0-only
iota-vm,8.0.0,GPL-2.0-only WITH Classpath-exception-2.0
kappa-parse,1.1.0,GPL-2.0-or-later WITH Bison-exception-2.2
lambda-old,0.5.0,GPL-2.0+
mu-old,0.6.0,GPL-2.0
nu-shell,5.2.0,GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)
xi-font,1.0.0,LicenseRef-vendor-eula
omicron-tool,3.1.0,Foo-1.0
pi-gfx,2.2.0,MIT AND Foo-1.0
rho-mpl,1.9.0,MPL-2.0
sigma-mixed,7.0.0,MIT OR (Apache-2.0 AND GPL-2.0-only)
tau-bad,0.1.0,MIT or Apache-2.0
upsilon-empty,0.2.0,
phi-slash,1.2.3,MIT/Apache-2.0
chi-lower,6.0.0,mit
psi-later,3.3.0,Apache-2.0+
import csv
import sys
from collections import Counter
from policy import FAMILIES, assess, decide
from spdx import SpdxError
with open("inventory.csv", encoding="utf-8", newline="") as stream:
rows = list(csv.DictReader(stream))
results = []
for row in rows:
try:
results.append((row, assess(row["license"]), None))
except SpdxError as error:
results.append((row, None, str(error)))
def action(verdict, scenario):
return "block" if verdict is None else decide(verdict, scenario)
print(f"== Nhóm ({len(rows)} gói)")
families = Counter("lỗi cú pháp" if verdict is None else verdict.family for _, verdict, _ in results)
for name in (*FAMILIES, "lỗi cú pháp"):
print(f"{name:17}: {families[name]}")
print("== Quyết định theo kịch bản")
for scenario, label in (("internal", "nội bộ"), ("distribute", "phân phối")):
counts = Counter(action(verdict, scenario) for _, verdict, _ in results)
print(f"{label:10}: allow {counts['allow']}, review {counts['review']}, block {counts['block']}")
print("== Chặn (block) vì nhóm chưa biết hoặc chuỗi sai")
for row, verdict, error in results:
if action(verdict, "distribute") == "block":
why = f"lỗi cú pháp: {error}" if verdict is None else "; ".join(text for _, text in verdict.flags)
print(f"{row['package']}: {why}")
print("== Nhánh OR đã chọn (ghi lại để biết nghĩa vụ nào đang áp dụng)")
for row, verdict, _ in results:
if verdict and verdict.choices:
print(f"{row['package']}: {', '.join(verdict.choices)} <- {row['license']}")
print("== Cần sửa metadata hoặc bổ sung chính sách")
for row, verdict, _ in results:
for kind, text in verdict.flags if verdict else ():
if kind in ("deprecated", "later", "exception"):
print(f"{row['package']}: [{kind}] {text}")
grep_hit = {row["package"] for row, _, _ in results if "gpl" in row["license"].lower()}
needs_look = {row["package"] for row, verdict, _ in results if action(verdict, "distribute") != "allow"}
print("== So với tìm chuỗi GPL (kịch bản phân phối)")
print(f"tìm chuỗi GPL báo {len(grep_hit)} gói; chính sách báo {len(needs_look)} gói")
print(f"báo thừa (chứa GPL nhưng chính sách cho phép): {len(grep_hit - needs_look)}")
print(f"bỏ sót (chính sách cần xem nhưng không chứa GPL): {len(needs_look - grep_hit)}")
blocked = sum(action(verdict, "distribute") == "block" for _, verdict, _ in results)
if blocked:
print(f"không đạt: {blocked} gói bị chặn")
sys.exit(1)
print("đạt")
python3 -B report.py
== Nhóm (23 gói)
permissive : 8
weak-copyleft : 3
strong-copyleft : 5
network-copyleft : 1
unknown : 3
lỗi cú pháp : 3
== Quyết định theo kịch bản
nội bộ : allow 16, review 1, block 6
phân phối : allow 8, review 9, block 6
== Chặn (block) vì nhóm chưa biết hoặc chuỗi sai
xi-font: LicenseRef-vendor-eula chưa có trong chính sách
omicron-tool: Foo-1.0 chưa có trong chính sách
pi-gfx: Foo-1.0 chưa có trong chính sách
tau-bad: lỗi cú pháp: thừa 'or' (toán tử SPDX phải viết hoa: AND, OR, WITH)
upsilon-empty: lỗi cú pháp: biểu thức rỗng
phi-slash: lỗi cú pháp: ký tự lạ '/' ở vị trí 3
== Nhánh OR đã chọn (ghi lại để biết nghĩa vụ nào đang áp dụng)
epsilon-ui: MIT <- MIT OR GPL-3.0-only
eta-json: Apache-2.0 <- Apache-2.0 OR MIT
nu-shell: MIT <- GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)
sigma-mixed: MIT <- MIT OR (Apache-2.0 AND GPL-2.0-only)
== Cần sửa metadata hoặc bổ sung chính sách
kappa-parse: [exception] WITH Bison-exception-2.2 chưa có trong chính sách
lambda-old: [deprecated] GPL-2.0+ là id cũ, dùng GPL-2.0-or-later
mu-old: [deprecated] GPL-2.0 là id cũ, dùng GPL-2.0-only
psi-later: [later] Apache-2.0+ chấp nhận cả bản mới hơn: nghĩa vụ có thể đổi theo bản
== So với tìm chuỗi GPL (kịch bản phân phối)
tìm chuỗi GPL báo 10 gói; chính sách báo 15 gói
báo thừa (chứa GPL nhưng chính sách cho phép): 2
bỏ sót (chính sách cần xem nhưng không chứa GPL): 7
không đạt: 6 gói bị chặn
Đọc kết quả:
- Chuỗi sai là một nhóm riêng, không phải
permissive. Ba dòngtau-bad,upsilon-emptyvàphi-slashbị chặn kèm lý do của bộ đọc: toán tử chữ thường (gợi ý phải viết hoa), ô trống, ký tự/ở vị trí 3. Đọc rộng tay, ba dòng này rất dễ thànhMIT. mitchữ thường hợp lệ.chi-lowerđược nhận làpermissivevì mã so khớp không phân biệt hoa thường, đúng như đặc tả; cũng chính đặc tả đòi toán tử viết hoa, nênMIT or Apache-2.0bị chặn.- Hai kịch bản cho hai bảng quyết định. Ở cột nội bộ chỉ
theta-sync(AGPL) cần xem, còn sáu gói bị chặn vì mã lạ hoặc chuỗi sai; ở cột phân phối thêm tám gói copyleft cần xem (weakba,strongnăm), tổng chín góireview. - Nhánh
ORđược ghi lại.epsilon-uivàsigma-mixedcó nhánh GPL nhưng chọnMITlà đủ, nên không bị báo ở kịch bản nào; báo cáo ghi nhánh đã chọn để người ký duyệt biết nghĩa vụ nào đang áp dụng. Chọn nhánh khác là một quyết định khác, không phải chi tiết. - Tìm chuỗi
GPLsai cả hai chiều trên mẫu này. Nó báo 10 gói, hai gói (epsilon-ui,sigma-mixed) báo thừa vì chọn được nhánhMIT; nó bỏ sót 7 gói chính sách cần xem (rho-mplthuộcweak-copyleft, mã lạ,LicenseRef-…và các chuỗi sai). Mẫu do bài dựng để có đủ các dạng, nên các con số chỉ chứng minh kiểu sai, không phải tỉ lệ ngoài đời; số liệu thật ở bước sau. - Việc còn lại có tên. Bốn dòng của mục metadata chỉ ra ba việc khác nhau: sửa mã cũ (
lambda-old,mu-old), cân nhắc dấu+(psi-later), và bổ sung chính sách cho ngoại lệ (kappa-parse).
Lab: dữ liệu thật từ Homebrew
Lưu brewcheck.py. Script chỉ chạy khi có brew: gọi brew info --json=v2 --installed với HOMEBREW_NO_AUTO_UPDATE=1 và HOMEBREW_NO_ANALYTICS=1, chỉ in số tổng hợp cùng mã giấy phép, không in tên gói. Lệnh có chủ ý chỉ đọc, nhưng bài không kiểm Homebrew có ghi thêm vào cache riêng của nó khi chạy hay không. Lệnh thứ hai chạy lại script với PATH trỏ vào thư mục không tồn tại để kiểm nhánh “không có brew”.
import json
import os
import shutil
import subprocess
from collections import Counter
from policy import FAMILIES, assess, decide
from spdx import SpdxError
brew = shutil.which("brew")
if brew is None:
print("Homebrew: không có trên máy này, bỏ qua")
print("kiểm tra Homebrew xong")
raise SystemExit(0)
env = {**os.environ, "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ANALYTICS": "1"}
done = subprocess.run(
[brew, "info", "--json=v2", "--installed"],
capture_output=True, text=True, check=True, env=env, timeout=300,
) # fmt: skip
data = json.loads(done.stdout)
licenses = [formula.get("license") for formula in data["formulae"]]
declared = [text for text in licenses if text]
print(f"formula đã cài: {len(licenses)}; có trường license: {len(declared)}; để trống: {len(licenses) - len(declared)}")
with_key = sum("license" in cask for cask in data["casks"])
print(f"cask đã cài: {len(data['casks'])}; cask có khóa license trong JSON: {with_key}")
verdicts, refused = [], []
for text in declared:
try:
verdicts.append((text, assess(text)))
except SpdxError:
refused.append(text)
print(f"chuỗi khác nhau: {len(set(declared))}; bộ đọc từ chối: {len(refused)}")
operators = Counter(op for text in declared for op in ("OR", "AND", "WITH") if f" {op} " in text)
print(f"chuỗi có OR: {operators['OR']}, có AND: {operators['AND']}, có WITH: {operators['WITH']}")
families = Counter(verdict.family for _, verdict in verdicts)
print("nhóm sau khi chọn nhánh OR: " + ", ".join(f"{name} {families[name]}" for name in FAMILIES))
for scenario, label in (("internal", "nội bộ"), ("distribute", "phân phối")):
counts = Counter(decide(verdict, scenario) for _, verdict in verdicts)
print(f"{label}: allow {counts['allow']}, review {counts['review']}, block {counts['block']}")
grep_hit = {n for n, (text, _) in enumerate(verdicts) if "gpl" in text.lower()}
needs_look = {n for n, (_, verdict) in enumerate(verdicts) if decide(verdict, "distribute") != "allow"}
print(f"tìm chuỗi GPL báo {len(grep_hit)} formula; chính sách (phân phối) báo {len(needs_look)}")
print(f"báo thừa: {len(grep_hit - needs_look)}; bỏ sót: {len(needs_look - grep_hit)}")
missed = Counter(verdicts[n][1].family for n in needs_look - grep_hit)
print("bỏ sót theo nhóm: " + ", ".join(f"{name} {missed[name]}" for name in FAMILIES if missed[name]))
unknown = Counter(text.split()[0] for _, verdict in verdicts for kind, text in verdict.flags if kind == "unknown")
common = ", ".join(f"{name} x{count}" for name, count in unknown.most_common(6))
print(f"id chưa có trong chính sách: {len(unknown)} loại; phổ biến nhất: {common}")
print("kiểm tra Homebrew xong")
python3 -B brewcheck.py
PATH=/nonexistent "$(command -v python3)" -B brewcheck.py
Kết quả trên máy tác giả (Homebrew 7.0.7, 2026-10-04; số trên máy bạn sẽ khác, nên khối này không có shows):
formula đã cài: 167; có trường license: 166; để trống: 1
cask đã cài: 15; cask có khóa license trong JSON: 0
chuỗi khác nhau: 62; bộ đọc từ chối: 0
chuỗi có OR: 16, có AND: 18, có WITH: 7
nhóm sau khi chọn nhánh OR: permissive 85, weak-copyleft 23, strong-copyleft 27, network-copyleft 0, unknown 31
nội bộ: allow 135, review 0, block 31
phân phối: allow 85, review 50, block 31
tìm chuỗi GPL báo 54 formula; chính sách (phân phối) báo 81
báo thừa: 2; bỏ sót: 29
bỏ sót theo nhóm: weak-copyleft 5, unknown 24
id chưa có trong chính sách: 46 loại; phổ biến nhất: Python-2.0 x4, OLDAP-2.8 x3, Unicode-3.0 x2, MIT-Modern-Variant x2, LGPL-2.0-or-later x2, MIT-CMU x2
kiểm tra Homebrew xong
Đọc kết quả:
- JSON của Homebrew đúng là biểu thức SPDX. Cả 166 chuỗi đọc được bằng bộ đọc ở trên (từ chối 0), trong đó 16 chuỗi có
OR, 18 cóANDvà 7 cóWITH. Ngoại lệ và hợp nhiều giấy phép không hiếm: bảng nhận diện phải hiểu chúng chứ không chỉ khớp tên một mã. - Cask không có metadata giấy phép trong JSON này. 15 cask đã cài, không cask nào có khóa
license; kiểm kê chỉ từ formula bỏ ngoài các ứng dụng cài bằng cask, nên cần nguồn khác cho phần đó. Một formula (1 trong 167) để trống trườnglicense: thiếu metadata cũng là một ca cần người xem. unknownlà phần lớn việc bảo trì. Với bảng 22 mã, 31 trong 166 formula (18,7%) rơi vàounknownvà bị chặn ở cả hai kịch bản. Chúng thuộc 46 loại mã khác nhau, mà mười mã phổ biến nhất bài đã kiểm đều là mã hiện hành của SPDX License List (kiểm ngày 2026-10-04):unknownnghĩa là chưa có trong bảng của bạn, không nghĩa là sai. Danh sách 3.29.0 có 708 mã hiện hành và 32 mã deprecated (đếm các thẻ mã trong HTML của trang), nên bảng 22 mã luôn chừa một đuôi dài. Mỗi mã thêm vào bảng là một lần người đọc giấy phép và quyết định nhóm; quy tắc theo tiền tố (như mọi mã bắt đầu bằngLGPL-) giảm công việc nhưng xếp nhầm các mã ngoại lệ, và bài không làm.- Tìm chuỗi
GPLbỏ sót nhiều hơn báo thừa. Nó báo 54 formula; chính sách ở kịch bản phân phối báo 81. Hai formula chứaGPLđược chính sách cho qua vì chọn được nhánh dễ hơn; 29 formula chính sách cần xem mà chuỗi không chứaGPL: 24 là mã lạ và 5 làweak-copyleftkhông có chữGPL(trên máy này là MPL-2.0). - Chọn kịch bản trước khi đọc con số. Ở cột nội bộ 135 formula
allowcòn ở cột phân phối chỉ 85, hiệu số 50reviewđều làweak-copylefthoặcstrong-copyleft. Kịch bản là đầu vào của quyết định; nó không suy ra được từ danh sách phần mềm.
Trên Ubuntu hoặc Debian (chưa chạy)
Bài chưa chạy gì trên Linux và không có số đo nào cho apt, dpkg hay snap. Điều bài biết từ nguồn: tệp copyright của gói Debian có thể theo định dạng ở trên với cú pháp khác SPDX (bảng so sánh); bài không khảo sát có bao nhiêu gói dùng định dạng máy đọc được. Khi chạy trên Linux, cần làm ba việc trước khi tin con số: lấy danh sách gói và chuỗi giấy phép từ nguồn có thể kiểm, đọc tay các chuỗi bị report.py từ chối (nhiều khả năng là chuỗi kiểu Debian), và dựng bảng ánh xạ tên Debian sang mã SPDX có đối chiếu với trang đặc tả. Pip, npm, cargo và snap là các kênh khác, mỗi kênh có metadata riêng mà bài không đo.
Quyết định và bẫy
| Tình huống | Quyết định | Căn cứ trong bài |
|---|---|---|
| Cột giấy phép là chuỗi tự do | Đọc bằng bộ đọc đúng đặc tả, không tìm chuỗi con | Mẫu giả báo thừa 2 và bỏ sót 7; Homebrew báo thừa 2 và bỏ sót 29 |
Gặp A OR B AND C | Đọc là A OR (B AND C) | Đặc tả: AND ưu tiên hơn OR; test ghim cây |
Gặp OR | Chọn nhánh, ghi nhánh đã chọn vào kiểm kê | Báo cáo mục “Nhánh OR đã chọn”; nghĩa vụ chỉ áp dụng cho giấy phép được chọn |
Gặp AND | Lấy nhóm khó nhất của các vế | Phải tuân thủ cả hai vế; MIT AND GPL-3.0-only là strong-copyleft |
Gặp WITH | Chỉ đổi nhóm khi ngoại lệ có trong chính sách và có người đọc | Classpath-exception-2.0 hạ GPL; Bison-exception-2.2 chỉ gắn cờ |
Gặp mã cũ như GPL-2.0 hoặc GPL-2.0+ | Đọc thành bản -only hoặc -or-later và gắn cờ sửa metadata | Bảng deprecated của SPDX: “only” và “or later” trong tên đầy đủ |
Gặp mã lạ hoặc LicenseRef-… | Chặn đến khi người đọc và thêm vào chính sách | Homebrew: 31 trong 166 formula (18,7%) với bảng 22 mã; mã phổ biến nhất vẫn là mã SPDX hợp lệ |
| Chuỗi sai cú pháp hoặc ô trống | Chặn, không đoán | tau-bad, upsilon-empty, phi-slash trong mẫu |
| Nguồn là cask hoặc kênh không có metadata | Liệt kê riêng, không coi là “không có giấy phép” | 15 cask, 0 có khóa license trong JSON |
| “Dùng nội bộ” nhưng chuyển bản cho nhà thầu | Chuyển sang kịch bản phân phối | GNU GPL FAQ #InternalDistribution: cung cấp bản sao cho nhà thầu dùng bên ngoài là phân phối |
| Chuỗi từ Debian | Không đưa thẳng vào bộ đọc SPDX; ánh xạ có kiểm hoặc đọc tay | Toán tử chữ thường, dấu phẩy đổi ưu tiên, with … exception là chữ tự do |
Giới hạn
- Bài không phải tư vấn pháp lý. Nhóm rủi ro, hai cột kịch bản và việc hạ nhóm theo ngoại lệ liên kết là chính sách mẫu của bài, dựng để minh họa; bài không đọc văn bản LGPL, MPL, EPL hay Apache-2.0 và không xét tương thích giữa các giấy phép trong cùng một sản phẩm. Các trích dẫn GNU GPL FAQ là câu trả lời cho câu hỏi cụ thể của từng mục, không thay cho việc đọc giấy phép của phần mềm bạn dùng.
- Bộ đọc theo đặc tả SPDX 2.3 (phụ lục D), bài không đọc các bản 3.x của đặc tả. Bộ đọc kiểm cú pháp, không kiểm mã và mã ngoại lệ có trong danh sách hay không; việc đó nằm ở chính sách (
unknown). Bài chưa chạy bộ đọc trên tập chuỗi giấy phép lớn nào ngoài 23 dòng giả và 166 chuỗi Homebrew của một máy. - Quy tắc mã cũ của GNU tổng quát hóa từ sáu hàng đã đọc và từ một câu của trang SPDX; các mã deprecated khác (ví dụ
AGPL-1.0, mã gộp ngoại lệ) không được ánh xạ. Mã thay thế-only,-or-laterlà suy luận của bài, chưa phải dữ liệu của bảng. - Số đo Homebrew thuộc một máy macOS arm64, Homebrew 7.0.7, ngày 2026-10-04. Chuỗi
licenselà dữ liệu do người đóng gói khai, bài không đối chiếu với giấy phép thật của từng formula và không xét phụ thuộc bắc cầu. Bài không kiểmbrew infocó ghi cache của Homebrew hay không; đã đặtHOMEBREW_NO_AUTO_UPDATE=1vàHOMEBREW_NO_ANALYTICS=1để giảm tác dụng phụ. - Chưa chạy trên Linux, apt/dpkg, snap, pip, npm hay cargo; bài không đo mức độ phủ của tệp
copyrightđịnh dạng máy đọc được và không viết bộ đổi Debian sang SPDX. - Lab không ghi tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.
Học tiếp và nguồn
- Setup macOS đầy đủ: cài và quản lý phần mềm bằng Homebrew và Brewfile (mục 2 và 3), nơi danh sách formula của bài có nguồn gốc.
- SPDX, SPDX License Expressions (đặc tả 2.3, phụ lục D): ngữ pháp, thứ tự ưu tiên, chữ hoa chữ thường,
+,WITH,LicenseRef. - SPDX, SPDX License List (bản 3.29.0, 2026-09-16): mã giấy phép và bảng “Deprecated License Identifiers”.
- SPDX, MIT License và Classpath-exception-2.0: văn bản điều kiện giữ thông báo của MIT và văn bản ngoại lệ liên kết.
- Debian, Machine-readable debian/copyright file (bản 1.0): cú pháp trường
License,and/or, dấu phẩy,with … exception, so sánh với SPDX. - Homebrew, Formula Cookbook: trường
license,:any_of,:all_of,:with,:public_domain. - GNU, GPL FAQ:
#GPLRequireSourcePostedPublic,#InternalDistribution,#LinkingWithGPL,#UnreleasedModsAGPL.
Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 và Homebrew 7.0.7 trên macOS arm64, không có nghiệm thu Linux.