Linting
pekit lint checks a recipe against a set of rules and reports every
violation in one pass. It is the mechanical half of a packaging review: the
things a person would otherwise verify by reading the recipe and running
readelf over the build, written down once and run everywhere.
pekit ships the rules but enables none of them by default. A
lint.pekit.toml file turns rules on and sets their parameters, so a
distribution decides what its recipes are held to and a standalone project
decides the same for itself. A tree with no lint file has nothing to lint,
and pekit lint says so (missing_lint_config).
Every rule is an error. There is no warning level: a rule is either on, or exempted for a stated reason. A warning would be a finding nobody has to justify.
Running it #
pekit lint
pekit lint --version 1.5.7
pekit workspace lint --json
With no version flag, an ordinary non-delegated recipe reads only its committed
recipe files, pekit.lock, and patch series. It resolves no source and touches
no network.
A delegated recipe is the deliberate exception. Plain pekit lint acquires
one source snapshot, loads the delegated pekit.toml, package files, and
lint.pekit.toml, merges them with the wrapper, and runs static rules against
that effective recipe. A templated, tracked-path, or PyPI source selects the
newest discoverable version; a fixed Git ref or fixed URL needs no synthetic
version; a local-only delegate uses its configured [source.local] tree. This
may contact the source and populate out_dir's source cache, but it runs no
target and needs no build stage.
With an explicit source/version flag (--version, --latest, --local),
lint performs the same effective-recipe checks and additionally checks the
payload each package would pack, read from an existing build stage. It
never builds: a missing stage is stage_missing, naming the pekit build to
run first. The rules that need a payload are marked in the tables below;
without an explicit selection they are skipped with a lint_skipped event
that says how many.
lint takes the version-selection and local-source flags, --env and
--keyring, and the global flags. It accepts no selectors and no --all:
every package a recipe defines is checked. pekit workspace lint fans out
across every member like any other command. A versioned run applies the
selected environment's [lint.allow]
exemptions, as the package gate does.
pekit package and pekit publish also run this policy, as the
lint gate: the static
rules against the recipe and the payload rules against the archives the run
just wrote. Any finding stops the run before publication.
Output is one lint event per finding, with rule, package and path
fields under --json, an event per exemption that applied (lint_allowed),
one per exemption that did not (lint_unused_allow), and a lint_summary.
The command fails with lint_failed when any finding stands.
The lint.pekit.toml file #
A rule is enabled by giving its key a value. A boolean rule takes true; a
rule with a real choice takes one of its enumerated strings; a rule with a
parameter takes the parameter. An absent key is a rule that does not run.
[]
= "reverse-dns"
= "spdx"
= true
= 80
[]
= true
= true
= true
[]
= true
= "full"
A rule's ID is its key path in the file: package.name.style,
elf.relro. That ID is what findings carry, what [allow] names and what
this page lists. Unknown keys are rejected (unknown_key), as is a value of
the wrong shape (invalid_value), so a misspelled rule cannot silently not
run.
Where files are found, and how they layer #
Files are found by walking up from the recipe directory to the workspace root. A recipe outside a workspace reads only its own, except that a delegated recipe also reads the file at its acquired source root. Plain lint performs that acquisition automatically; it is not necessary to request payload lint just to apply the source project's rules.
They merge outermost first, nearest winning per key, in this order:
- the delegated source root, when there is one;
- the workspace root;
- each directory down to the recipe directory;
- the recipe directory.
The source root comes first, not last, deliberately. A project may carry a lint file that holds itself to a higher bar than the distribution packaging it, but it cannot relax the distribution's rules.
A nearer file may re-parameterise a rule (description = 40 under a
workspace that says 80) but may not switch one off. Setting a rule
that an outer file enabled to false is refused (lint_rule_disabled).
Switching a rule off goes through [allow].
[allow]: exemptions with reasons #
For a payload exception limited to particular files, prefer allow_files:
[]
= "startup object linked into user programs"
Keys are case-sensitive, package-root-relative payload paths or glob patterns
(* matches within one path component; ** can cross directories). They match
destinations, not build-host source paths. Every entry requires a nonempty reason
and a known payload rule; absolute paths, parent traversal and malformed patterns
are rejected. Other files and other rules remain enforced. Exceptions apply to
all splits of the recipe, so keep paths narrow. Configurations merge per rule and
pattern, nearest winning for the same pair. Matching findings remain visible as
lint_allowed, and unused entries produce lint_unused_allow. A whole-rule
allow takes precedence; otherwise the first matching pattern in lexical order
is used. Neither form enables a rule that is disabled or absent.
Use a whole-rule exemption only when it genuinely applies to the entire recipe:
[]
= "non-public: grants unauthenticated SYSTEM access to a DWE guest"
= "upstream ships no manual pages; tracked in PEI-27"
An [allow] entry names a rule and gives a reason. The reason is mandatory
(missing_reason) and may not be empty. While the entry stands, that rule's
findings for the recipe are reported as lint_allowed rather than failing
the run. An entry that never applies is reported as lint_unused_allow, so
an exemption outlives its cause visibly and can be removed.
[allow] may appear in any file in the chain, but it belongs as close to
the recipe as the exemption is true for. Under --json, a workspace-wide
pekit workspace lint lists every exemption in the tree and its reason: the
review list of what is not yet up to standard and why.
Rules #
Each rule is general packaging hygiene, of the kind lintian, rpmlint, rpminspect or a Homebrew audit also checks. Anything distribution-specific is a parameter, never a value baked into pekit. Rules marked payload need a staged build and run only with a version flag.
[package] #
Checked for each package definition the recipe produces.
| Rule | Value | Checks |
|---|---|---|
package.name.style | "reverse-dns" or "lowercase" | Every package name, and the recipe directory when the recipe is a workspace member, matches the style. Reverse-DNS is org.example.thing; lowercase is any [a-z0-9._+-] name. |
package.license | true or "spdx" | A license is declared. With "spdx", the expression also parses as SPDX: identifiers joined by upper-case AND / OR, WITH for exceptions, balanced parentheses. The SPDX license list itself is not consulted. |
package.license_class | true | A peipkg-format package declares license_class. Other formats are not checked. |
package.homepage | true or "https" | A homepage is declared; with "https", it is an https URL. |
package.description | true or a length | A description exists, is one line, is at most the given length (80 when true), does not end with a full stop, and does not start with the package name or its last dotted label. |
package.dependencies | "consistent" | No self-dependency; no dependency on a name the package itself provides; no name both depended on and conflicted with; no name in both [dependencies] and [optional_dependencies]; no provide or conflict on the package's own name; no dependency, optional dependency or provide whose version spells out "{{version}}-N" by hand (use "= {{release}}"). |
package.references | "reverse-dns" | Every concrete package reference in package metadata and every Peipkg target dependency uses its canonical reverse-DNS name. Structured virtual capabilities such as pkgconfig(foo), python(abi) and ELF SONAMEs remain valid. Other deliberately unqualified interfaces must be listed in package.virtual_capabilities. |
package.architecture | "consistent" payload | A package declared architecture-independent ships no ELF object and nothing under an architecture-specific directory; a package declared for an architecture ships at least one such thing. The architecture-independent name is the package.noarch parameter, default noarch. |
Architecture-specific directory inference uses wildcard entries in
payload.dirs.lib, such as usr/lib/*-linux-*, and the split-debug directory
usr/lib/debug. Source text under usr/src/debug does not become
architecture-specific merely because of that directory name. ELF objects are
still detected there. Portable scripts and data may remain noarch when they
depend on native packages: those dependencies are resolved for the target
machine, not inherited as the portable package's architecture. Actual ELF or
architecture-specific directory content in a noarch package is still reported.
For an explicitly architecture-constrained source or header split, a required
architecture-specific sibling can justify that constraint. This payload check
cannot infer external dependency architectures; a genuinely architecture-tied
package without visible payload or sibling evidence needs a documented exception.
package.virtual_capabilities is an array parameter shared by the
package.references checks. It is for genuine interchangeable interfaces such
as sh or an operating-system role, never for a compatibility alias for a
concrete package. For example:
[]
= "reverse-dns"
= ["init", "sh"]
[source] #
Checked against the recipe's [source] table. Rules for a source kind the
recipe does not use do nothing.
| Rule | Value | Checks |
|---|---|---|
source.reproducible | true | The source is not [source.local] alone. A recipe with no source at all passes. |
source.discovery | true | A URL source has listing_url; a git source (other than a tracked path) has tag_regex. Without them new upstream releases cannot be found, and every update is by hand. |
source.versions.floor | true | A discovering source has a versions constraint with a lower bound: >=, >, =, ^ or ~. A < x ceiling alone, *, or no constraint follows any release upstream ever published, including ones older than the recipe. Ceilings are never required. |
source.versions.ceiling | "none" | No versions constraint bounds from above: no < or <= term, no = pin, no ^ or ~ range. A ceiling is where unattended updates silently stop; a new major is reviewed when it arrives, not fenced off in advance. |
source.ref | "immutable" | A git ref is a template (v{{version}}), a full commit hash, a refs/tags/ path or a tag-like v1.2, not a branch name that the lock could never pin. |
source.url.scheme | "https" | Every source URL (url, listing_url, signature and patch-series URLs) is https. A git URL may also be ssh://, git+ssh:// or user@host:path. |
source.lock | true | pekit.lock exists with at least one entry; every URL entry carries a sha256 (and each patch its own); every git entry a commit hash; and, when the recipe verifies signatures, every URL entry records the key that verified it. |
source.signature.required | true | A URL source has a [source.url.signature] block, and a git source (tracked paths included) a [source.git.signature] block. An upstream that publishes nothing verifiable is recorded as a reasoned allowance. |
source.signature.fingerprint | "full" | A signature block lists at least one fingerprint, and each is the full 40- or 64-hex-digit fingerprint, never a short key id. |
source.signature.keys | true | Every key_files entry exists and is not empty. |
source.patches.headers | true | Every patch in the series has, ahead of its first hunk, a non-empty Description: or Subject: line and an Origin:, Author: or From: line (DEP-3). git format-patch output satisfies this as written. |
source.patches.status | true | Every patch in the series records where it stands upstream: Forwarded: (a URL, no or not-needed), Applied-Upstream:, Bug:, or an Origin: that begins upstream or backport. |
[build] #
| Rule | Value | Checks |
|---|---|---|
build.dependencies.providers | array of provider names | Every build and test target declares a [build.<name>.dependencies.<provider>] (or [test.<name>.dependencies.<provider>]) table for each listed provider. An empty table is an explicit "none". |
build.test | true | The recipe defines a test target. A delegate recipe whose targets live in its source is not checked. |
[payload] #
All payload rules. Paths are payload destinations, slash-separated, relative to the package root.
Claim metadata uses canonical absolute paths in the installed logical root,
such as /usr/bin/sh. Staged and final-archive lint convert these to
package-relative keys when indexing claim slots; the leading slash does not
refer to the build host. Relative, root-only and noncanonical claim paths
(including .. components) are not valid claim-slot exemptions.
| Rule | Value | Checks |
|---|---|---|
payload.junk | true or an array of patterns | No destination matches a build-leftover pattern. true uses the built-in list: **/*.la, **/*.orig, **/*.rej, **/*.o, **/*~, **/*.swp, **/.git, **/.git/**, **/.gitignore, **/CMakeCache.txt, **/.DS_Store, usr/share/info/dir, **/perllocal.pod, **/.packlist. An array replaces it; "@default" in the array includes it. A .pyc without its .py is always reported. |
payload.scripts | true | A file with a #! line is executable; its interpreter is an absolute path that is shipped by this recipe or listed in payload.interpreters (for #!/usr/bin/env prog, both env's own path and prog must be allowed); and, for sh, dash, ash and bash, the host shell's -n syntax check passes. |
payload.symlinks.dangling | "forbidden" | Every symlink resolves to something shipped by one of the recipe's packages. Claim slot paths, which point across packages by design, are skipped. |
payload.symlinks.absolute | "forbidden" | No symlink has an absolute target. |
payload.filenames | "portable" | Every destination is printable ASCII with no whitespace. |
payload.special_files | "forbidden" | No device node, fifo or socket. |
payload.manpages.required | true | Every file in a bin directory has a manual page, shipped by any package of the recipe, named after it in any section. |
payload.manpages.compression | "gzip" or "none" | Every file under a man directory is gzip-compressed, or none is. |
payload.pkgconfig | true | No .pc file contains the build directory path. |
payload.license_file | a path template | Every package ships a regular file under the rendered directory. {{name}} is the package name; version tokens render as in a recipe. |
Parameters, all optional: payload.interpreters (default /bin/sh,
/usr/bin/sh, /bin/bash, /usr/bin/bash, /usr/bin/env,
/usr/bin/python3, /usr/bin/perl), payload.dirs.bin (default bin,
sbin, usr/bin, usr/sbin), payload.dirs.lib (default lib, lib64,
usr/lib, usr/lib64, lib/*-linux-*, usr/lib/*-linux-*) and
payload.dirs.man (default usr/share/man, share/man). Directory
parameters are glob patterns matched against a destination's directory.
[split] #
| Rule | Value | Checks |
|---|---|---|
split.devel.packages | a name pattern, or an array | payload. Development files land only in packages whose names match: headers, pkg-config files, CMake files, aclocal macros (the split.devel.files patterns, default usr/include/**, include/**, **/pkgconfig/*.pc, **/cmake/**, usr/share/aclocal/**, usr/lib/**/*.cmake) and unversioned .so symlinks in a lib directory. After ten findings in one package the rest are counted. |
Empty directories owned by a filesystem skeleton are not development files. The split check applies to the files and symlinks that provide the interface.
[elf] #
All payload rules, checked for every ELF program or shared library in
every package. Relocatable objects, kernel modules and anything under
usr/lib/debug/ or usr/src/ are skipped.
| Rule | Value | Checks |
|---|---|---|
elf.pie | true | No ET_EXEC program; executables are position-independent so address-space randomisation applies. |
elf.stack | "non-exec" | A PT_GNU_STACK header is present and does not request an executable stack. |
elf.relro | "partial" or "full" | A dynamic object has a PT_GNU_RELRO segment; with "full", it is also bound at load (BIND_NOW), so the GOT is read-only afterwards. |
elf.relr | true | A dynamic object with relative relocations left in .rela.dyn has none, or carries DT_RELR; the linker flag is -z pack-relative-relocs. |
elf.cet | true | An x86 object carries both IBT and SHSTK GNU properties, and inspected indirect-call entry points begin with ENDBR32/ENDBR64. Requires local symbols or matching build-ID debuginfo. Other architectures are not checked. |
elf.rpath | "forbidden" | No DT_RPATH or DT_RUNPATH. |
elf.textrel | "forbidden" | No text relocations. |
elf.stripped | true | No .debug_* sections and no .symtab. |
elf.debuginfo | a package-name template | The recipe has a package named by the template ({{name}} is the shipping package) and, for every object with a build-id, that package ships usr/lib/debug/.build-id/xx/yyyy.debug. An object without a build-id is reported. |
elf.build_paths | "forbidden" | The object does not embed the build directory path anywhere in its bytes. |
elf.soname | true | A lib*.so* regular file directly in a lib directory has a DT_SONAME, and its file name is the soname or the soname followed by more version components. |
What lint does not check #
Whether a package's role is documented, whether its split is sensible, whether a service runs under the right identity, and whether its files sit in the places a particular distribution mandates. The first three are review judgement; the last is pack-time validation in the package format, not a lint rule.
What CET validation establishes #
elf.cet checks the runtime payload's machine code, using exported function
symbols, the main callback passed to libc, and relocated pointers to known
functions in loaded data. It supports x86 REL, RELA and packed RELR relocations.
For stripped objects it obtains symbols from a debug file shipped by this
recipe at usr/lib/debug/.build-id/xx/yyyy.debug, and verifies the build ID,
machine and ELF class before using its addresses. The instruction bytes always
come from the runtime payload. Missing symbols, unreadable evidence and objects
with no supported targets produce findings rather than a reduced-coverage pass.
Functions reached only by direct calls, jump-table labels and the process entry point do not automatically require ENDBR. This check is bounded static evidence: it cannot enumerate computed or generated targets, prove shadow-stack compatibility, validate all inline assembly, or establish that a running system actually enables CET. Retain upstream tests and runtime enforcement tests where these behaviors matter.
Compile the code with the relevant protection option, including linked runtime
and assembly inputs. GNU ld's -z ibt and -z shstk force compatibility markers;
they cannot substitute for instrumentation. -z ibtplt generates IBT PLT entries
without overriding the input-note merge. On a native toolchain that provides
marked inputs, use -z cet-report=error to reject missing properties during
linking. Do not combine that check with forced properties: forcing suppresses
the corresponding missing-property diagnostic. See the
GNU linker options.
Rust uses its own control-flow protection code-generation option.
Its linked standard library must also be compatible. Older dependencies may
probe unstable features in build scripts: enabling RUSTC_BOOTSTRAP globally
can change those probes. A recipe that requires an unstable code-generation
option should confine that access to actual compiler code generation and test
the resulting executable, as well as the complete linked input set.
If a supported upstream/toolchain combination cannot meet the rule, document a
narrow allow_files."elf.cet" exception with the affected payload path, reason
and removal condition. Keep the resulting lack of compatibility visible; do
not manufacture a note to obtain a passing lint result.