///|
/// Configuration for policy linting.
pub struct LintOptions {
require_wildcard_group : Bool
require_sitemap : Bool
warn_unknown_directives : Bool
max_crawl_delay_millis : Int
max_rules_per_group : Int
} derive(Eq, Debug)
///|
pub fn default_lint_options() -> LintOptions {
{
require_wildcard_group: true,
require_sitemap: false,
warn_unknown_directives: true,
max_crawl_delay_millis: 60000,
max_rules_per_group: 5000,
}
}
///|
pub fn strict_lint_options() -> LintOptions {
{
require_wildcard_group: true,
require_sitemap: true,
warn_unknown_directives: true,
max_crawl_delay_millis: 30000,
max_rules_per_group: 1000,
}
}
///|
pub fn LintOptions::require_wildcard_group(self : LintOptions) -> Bool {
self.require_wildcard_group
}
///|
pub fn LintOptions::require_sitemap(self : LintOptions) -> Bool {
self.require_sitemap
}
///|
pub fn LintOptions::warn_unknown_directives(self : LintOptions) -> Bool {
self.warn_unknown_directives
}
///|
pub fn LintOptions::max_crawl_delay_millis(self : LintOptions) -> Int {
self.max_crawl_delay_millis
}
///|
pub fn LintOptions::max_rules_per_group(self : LintOptions) -> Int {
self.max_rules_per_group
}
///|
fn lint_diag(
severity : Severity,
code : String,
message : String,
line : Int,
hint : String,
) -> Diagnostic {
make_diagnostic(severity, code, message, line, hint)
}
///|
fn group_contains_agent(group : Group, target : String) -> Bool {
array_contains_string(group.agents, target)
}
///|
fn has_wildcard_group(policy : Policy) -> Bool {
for group in policy.groups {
if group_contains_agent(group, "*") {
return true
}
}
false
}
///|
fn group_has_block_all(group : Group) -> Bool {
for rule in group.rules {
if rule.kind == Disallow && rule.pattern == "/" {
return true
}
}
false
}
///|
fn group_has_allow_root(group : Group) -> Bool {
for rule in group.rules {
if rule.kind == Allow && rule.pattern == "/" {
return true
}
}
false
}
///|
fn rule_key(rule : Rule) -> String {
"\{rule.kind.label()}:\{rule.normalized_pattern}"
}
///|
fn agent_occurrences(policy : Policy, agent : String) -> Int {
let mut count = 0
for group in policy.groups {
if group_contains_agent(group, agent) {
count = count + 1
}
}
count
}
///|
fn invalid_percent_index(value : String) -> Int {
let chars = value.to_array()
let mut index = 0
while index < chars.length() {
if chars[index] == '%' {
if index + 2 >= chars.length() ||
hex_value(chars[index + 1]) < 0 ||
hex_value(chars[index + 2]) < 0 {
return index
}
index = index + 3
} else {
index = index + 1
}
}
-1
}
///|
fn dollar_not_terminal(pattern : String) -> Bool {
let chars = pattern.to_array()
for index, char in chars {
if char == '$' && index != chars.length() - 1 {
return true
}
}
false
}
///|
fn duplicate_rule_count(group : Group, rule : Rule) -> Int {
let key = rule_key(rule)
let mut count = 0
for candidate in group.rules {
if rule_key(candidate) == key {
count = count + 1
}
}
count
}
///|
fn conflicting_rule(group : Group, rule : Rule) -> Bool {
for candidate in group.rules {
if candidate.normalized_pattern == rule.normalized_pattern &&
candidate.kind != rule.kind {
return true
}
}
false
}
///|
fn shadowed_by_identical_prefix(group : Group, index : Int) -> Bool {
let rule = group.rules[index]
for previous = 0; previous < index; previous = previous + 1 {
let candidate = group.rules[previous]
if candidate.kind == rule.kind &&
candidate.normalized_pattern == rule.normalized_pattern {
return true
}
}
false
}
///|
fn lint_group(
policy : Policy,
group : Group,
options : LintOptions,
output : Array[Diagnostic],
) -> Unit {
if group.rules.length() == 0 && group.crawl_delay_millis < 0 {
output.push(
lint_diag(
Info,
"LNT004",
"user-agent group has no effective records",
group.start_line,
"Add rules or remove the empty group.",
),
)
}
if group.rules.length() > options.max_rules_per_group {
output.push(
lint_diag(
Warning,
"LNT005",
"group has more rules than the configured maintainability threshold",
group.start_line,
"Split agents or simplify overlapping rules.",
),
)
}
if group.crawl_delay_millis > options.max_crawl_delay_millis {
output.push(
lint_diag(
Warning,
"LNT006",
"crawl-delay exceeds the configured operational threshold",
group.start_line,
"Confirm that such a slow crawl cadence is intentional.",
),
)
}
if group_contains_agent(group, "*") && group.agents.length() > 1 {
output.push(
lint_diag(
Warning,
"LNT007",
"wildcard and named agents share one group",
group.start_line,
"Separate wildcard and named agents to avoid crawler differences.",
),
)
}
if group_contains_agent(group, "*") && group_has_block_all(group) {
output.push(
lint_diag(
Warning,
"LNT008",
"wildcard group blocks the entire site",
group.start_line,
"Confirm that all unspecified crawlers should be denied.",
),
)
}
if group_has_block_all(group) && group_has_allow_root(group) {
output.push(
lint_diag(
Warning,
"LNT009",
"group contains equivalent root allow and disallow rules",
group.start_line,
"Allow wins the tie; remove the misleading rule.",
),
)
}
for agent in group.agents {
if agent != "*" && agent_occurrences(policy, agent) > 1 {
let first = first_group_line_for_agent(policy, agent)
if group.start_line != first {
output.push(
lint_diag(
Info,
"LNT010",
"agent appears in multiple groups; their rules are merged",
group.start_line,
"Keep related rules together when practical.",
),
)
}
}
}
for index, rule in group.rules {
if rule.pattern.length() > 0 && !rule.pattern.has_prefix("/") {
output.push(
lint_diag(
Warning,
"LNT011",
"path rule does not begin with `/`",
rule.line,
"Use an origin-relative path.",
),
)
}
if invalid_percent_index(rule.pattern) >= 0 {
output.push(
lint_diag(
Warning,
"LNT012",
"path rule contains malformed percent encoding",
rule.line,
"Write percent escapes as `%` followed by two hexadecimal digits.",
),
)
}
if dollar_not_terminal(rule.pattern) {
output.push(
lint_diag(
Warning,
"LNT013",
"`$` only acts as an end anchor at the end of a rule",
rule.line,
"Move `$` to the end or percent-encode a literal dollar sign.",
),
)
}
if rule.pattern.has_suffix("*") {
output.push(
lint_diag(
Info,
"LNT014",
"trailing wildcard is redundant",
rule.line,
"Remove the final `*` without changing prefix-match behavior.",
),
)
}
if count_char(rule.pattern, '*') > 8 {
output.push(
lint_diag(
Warning,
"LNT015",
"rule contains many wildcards and may be expensive to evaluate",
rule.line,
"Prefer a smaller number of targeted rules.",
),
)
}
if duplicate_rule_count(group, rule) > 1 &&
shadowed_by_identical_prefix(group, index) {
output.push(
lint_diag(
Info,
"LNT016",
"duplicate rule has no additional effect",
rule.line,
"Remove the duplicate rule.",
),
)
}
if conflicting_rule(group, rule) && rule.kind == Disallow {
output.push(
lint_diag(
Info,
"LNT017",
"equivalent Allow rule wins over this Disallow rule",
rule.line,
"Remove one side or make one path more specific.",
),
)
}
if rule.pattern.contains("?") {
output.push(
lint_diag(
Info,
"LNT018",
"rule matches a query component as well as a path",
rule.line,
"Confirm that query-sensitive crawling behavior is intended.",
),
)
}
}
}
///|
fn first_group_line_for_agent(policy : Policy, agent : String) -> Int {
for group in policy.groups {
if group_contains_agent(group, agent) {
return group.start_line
}
}
0
}
///|
/// Performs semantic and maintainability checks beyond parsing.
pub fn lint_with_options(
policy : Policy,
options : LintOptions,
) -> Array[Diagnostic] {
let output : Array[Diagnostic] = []
if policy.groups.length() == 0 {
output.push(
lint_diag(
Warning,
"LNT001",
"policy defines no user-agent groups",
1,
"Add a User-agent group or intentionally serve an empty file.",
),
)
}
if options.require_wildcard_group && !has_wildcard_group(policy) {
output.push(
lint_diag(
Info,
"LNT002",
"policy has no wildcard fallback group",
1,
"Add `User-agent: *` when unspecified crawlers need explicit rules.",
),
)
}
if options.require_sitemap && policy.sitemaps.length() == 0 {
output.push(
lint_diag(
Info,
"LNT003",
"policy does not advertise a sitemap",
1,
"Add an absolute Sitemap URL when the site publishes one.",
),
)
}
for group in policy.groups {
lint_group(policy, group, options, output)
}
if options.warn_unknown_directives {
for directive in policy.directives {
if directive.kind == UnknownDirective {
output.push(
lint_diag(
Info,
"LNT019",
"non-standard directive may be ignored by crawlers",
directive.line(),
"Document the target crawler or remove the extension.",
),
)
}
}
}
for sitemap in policy.sitemaps {
let parsed = parse_url(sitemap)
if !parsed.valid() {
output.push(
lint_diag(
Warning,
"LNT021",
"sitemap directive is not a valid absolute HTTP or HTTPS URL",
sitemap_directive_line(policy, sitemap),
"Use an absolute URL such as https://example.com/sitemap.xml.",
),
)
} else if parsed.fragment().length() > 0 {
output.push(
lint_diag(
Warning,
"LNT020",
"sitemap URL contains a fragment",
sitemap_directive_line(policy, sitemap),
"Remove the fragment from the sitemap location.",
),
)
}
}
output
}
///|
fn sitemap_directive_line(policy : Policy, sitemap : String) -> Int {
for directive in policy.directives {
if directive.kind == SitemapDirective && directive.value == sitemap {
return directive.line()
}
}
1
}
///|
pub fn lint(policy : Policy) -> Array[Diagnostic] {
lint_with_options(policy, default_lint_options())
}
///|
pub fn all_diagnostics_with_options(
policy : Policy,
options : LintOptions,
) -> Array[Diagnostic] {
let output : Array[Diagnostic] = []
for diagnostic in policy.diagnostics {
output.push(diagnostic)
}
for diagnostic in lint_with_options(policy, options) {
output.push(diagnostic)
}
output
}
///|
pub fn all_diagnostics(policy : Policy) -> Array[Diagnostic] {
all_diagnostics_with_options(policy, default_lint_options())
}
///|
pub fn diagnostics_at_least(
diagnostics : Array[Diagnostic],
severity : Severity,
) -> Array[Diagnostic] {
let output : Array[Diagnostic] = []
for diagnostic in diagnostics {
if diagnostic.severity.rank() >= severity.rank() {
output.push(diagnostic)
}
}
output
}
///|
pub fn has_diagnostic_code(
diagnostics : Array[Diagnostic],
code : String,
) -> Bool {
for diagnostic in diagnostics {
if diagnostic.code == code {
return true
}
}
false
}