Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions .agents/skills/add-or-modify-hook/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,31 +48,36 @@ New sub-checks that get added to `verify-dependencies` should have new modules
created in `rapids_pre_commit_hooks.dependencies`, and the check functions
should be added to `rapids_pre_commit_hooks.dependencies.check_dependencies()`.

## Agent Instructions
In cases where an automatic fix is not possible, hooks should generally provide
instructions to agents with the `add_agent_instructions()` method. These
instructions should give the agents as much context about the problem as is
needed to solve it.

Natural language text should have no indentation in the string, and code blocks
should be indented with two spaces. Agent instructions should generally be
returned from a function named `agent_instructions_*()` that has no positional
arguments. The function may optionally have required keyword arguments.

If running the function is computationally expensive, requires internet or file
access, or is otherwise nontrivial to run, it should be decorated with the
`@agent_instructions` decorator. This decorator causes the function to instead
return a no-argument lambda that invokes the function lazily.

# Tests
All tests can be found in the `tests` directory.

## Unit Tests and Integration Tests
Unit tests and some integration tests can be found in
`tests/rapids_pre_commit_hooks`. These tests make heavy use of
`rapids_pre_commit_hooks_test_utils.parse_named_spans()`. This function
provides a DSL that is meant for easily picking spans (`tuple[int, int]`) out
of a string of text rather than hard-coding the integers. All tests that use
spans should use `parse_named_spans()`, except tests that test the testing
infrastructure itself. See the documentation for `parse_named_spans()` for
a description of the DSL, and see the existing tests for examples.

Parameterized tests should generally only have one dimension. Parameters
should be listed as an array rather than as a single string. Each parameterized
entry should be declared with `pytest.param()` and a sensible `id` named in
kebab-case (`name-of-test`).
See the [write-test](../write-test/SKILL.md) skill for general unit test
guidelines.

Unit tests and some integration tests for hooks can be found in
`tests/rapids_pre_commit_hooks`.

In most cases, `zip_expected_warnings()` should be used to construct a list of
expected warnings from the named spans and the parallel warning metadata, both
of which should come from the test parameterization.

Test files generally do not require type annotations.

When in doubt, follow the existing code conventions present in the tests.
of which should come from the test parameterization. Agent instructions do not
need to be included in these test parameterizations.

### YAML Tests
Tests that operate on YAML text should use
Expand Down
24 changes: 24 additions & 0 deletions .agents/skills/write-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
name: write-test
description: Write tests for rapids-pre-commit-hooks.
---
# Test Guidelines
All tests can be found in the `tests` directory.

## Unit Tests and Integration Tests
The tests for rapids-pre-commit-hooks make heavy use of
`rapids_pre_commit_hooks_test_utils.parse_named_spans()`. This function
provides a DSL that is meant for easily picking spans (`tuple[int, int]`) out
of a string of text rather than hard-coding the integers. All tests that use
spans should use `parse_named_spans()`, except tests that test the testing
infrastructure itself. See the documentation for `parse_named_spans()` for
a description of the DSL, and see the existing tests for examples.

Parameterized tests should generally only have one dimension. Parameters
should be listed as an array rather than as a single string. Each parameterized
entry should be declared with `pytest.param()` and a sensible `id` named in
kebab-case (`name-of-test`).

Test files generally do not require type annotations.

When in doubt, follow the existing code conventions present in the tests.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ repos:
args:
- --py310-plus
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.8
rev: v0.17.1
hooks:
- id: ruff-check
args: [--fix]
Expand Down
8 changes: 3 additions & 5 deletions src/rapids_pre_commit_hooks/codeowners.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import argparse
Expand Down Expand Up @@ -54,10 +54,8 @@ def hard_coded_codeowners(owners: dict[str, str]) -> CodeownersTransform:


def project_codeowners(category: str) -> CodeownersTransform:
return (
lambda *,
org,
project_prefix: f"@{org}/{project_prefix}-{category}-codeowners"
return lambda *, org, project_prefix: (
f"@{org}/{project_prefix}-{category}-codeowners"
)


Expand Down
72 changes: 70 additions & 2 deletions src/rapids_pre_commit_hooks/hardcoded_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import bisect
import re
from enum import Enum
from textwrap import dedent
from typing import TYPE_CHECKING

import tomlkit
Expand Down Expand Up @@ -219,6 +220,67 @@ def read_version_file(
)


def agent_instructions_hardcoded_version(*, version_file: str) -> str:
return dedent(
f'''\
This check exists to ensure that RAPIDS packages do not hard-code the
RAPIDS version in a script when programmatically reading from the
{version_file} file would suffice. Doing so reduces maintenance
burden and reduces the possibility of hard-coded versions in scripts
neglecting to be updated in a version bump.

This check is very prone to false positives. The following conditions
can trigger a false positive:

1. Floating-point numbers in code that incidentally happen to equal the
version number even with no relation.

2. Code documentation that states when a function was added,
deprecated, etc.

3. Files that genuinely use the version but are incapable of
programmatically reading from the {version_file} file.

This check has some heuristics to catch most of these false positives,
but due to the nature of the problem, we can't catch everything. Choose
one of the following options to resolve this problem (in order of
preference):

1. If you are writing a script that already reads from the
{version_file} file, use the stored result from that read.

2. If you are writing a script that is capable of doing so, but does
not yet do so, programmatically read from the {version_file} file.

3. If you are writing documentation, comments, etc., you have likely
encountered a false positive. You may optionally file an issue or PR
at https://github.com/rapidsai/pre-commit-hooks to add a heuristic to
suppress the false positive.

4. Suppress the false positive with a next-line directive. For example:

// rapids-pre-commit-hooks: disable-next-line[verify-hardcoded-version]
// This function was added to the library as of 26.10
void new_function();

Or use a block directive:

def new_function():
# rapids-pre-commit-hooks: disable[verify-hardcoded-version]
"""New function that does stuff.

Added to the library as of 26.10
"""
# rapids-pre-commit-hooks: enable[verify-hardcoded-version]
pass

5. If you are writing a file in a format that has no mechanism for
writing comments, or if false positives are extremely common in the
file format, add an exclusion to .pre-commit-config.yaml.
''' # noqa: E501
)


def check_hardcoded_version(
linter: Linter, args: "argparse.Namespace"
) -> None:
Expand All @@ -244,18 +306,24 @@ def check_hardcoded_version(
if skip_heuristics(linter.lines, match):
continue

linter.add_warning(
w = linter.add_warning(
match.span("full"),
f"do not hard-code version, read from {args.version_file} "
"file instead",
).add_note(
)
w.add_note(
match.span("full"),
"if this is intentional (as part of a docstring or deprecation "
"notice), suppress it with rapids-pre-commit-hooks: "
"disable-next-line[verify-hardcoded-version] - see "
"https://github.com/rapidsai/pre-commit-hooks/blob/main/README.md"
"#suppressing-false-positives for details",
)
w.add_agent_instructions(
agent_instructions_hardcoded_version(
version_file=args.version_file
)
)


def main() -> None:
Expand Down
102 changes: 100 additions & 2 deletions src/rapids_pre_commit_hooks/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
import functools
import re
import warnings
from typing import TYPE_CHECKING
from typing import ParamSpec, TYPE_CHECKING

from rich.console import Console
from rich.markup import escape

from . import agents
from .utils import agents

if TYPE_CHECKING:
from collections.abc import Callable, Generator, Iterator
Expand All @@ -22,6 +22,27 @@
Span = tuple[int, int]


Args = ParamSpec("Args")


def agent_instructions(
func: "Callable[Args, str]",
) -> "Callable[Args, Callable[[], str]]":
"""Decorator for agent instructions functions that might be costly to run.

This function exists because some agent instructions might be costly to
run, and should only be run if their results will actually be printed.
"""

@functools.wraps(func)
def instructions(
*args: Args.args, **kwargs: Args.kwargs
) -> "Callable[[], str]":
return lambda: func(*args, **kwargs)

return instructions


class BinaryFileWarning(Warning):
pass

Expand All @@ -47,13 +68,24 @@ class LintWarning:
default_factory=list, kw_only=True
)
notes: list[Note] = dataclasses.field(default_factory=list, kw_only=True)
agent_instructions: "Optional[Callable[[], str]]" = dataclasses.field(
default=None, compare=False
)

def add_replacement(self, span: Span, newtext: str) -> None:
self.replacements.append(Replacement(span, newtext))

def add_note(self, span: Span, msg: str) -> None:
self.notes.append(Note(span, msg))

def add_agent_instructions(
self, instructions: "str | Callable[[], str]"
) -> None:
if isinstance(instructions, str):
self.agent_instructions = lambda: instructions
else:
self.agent_instructions = instructions


class Lines:
@functools.total_ordering
Expand Down Expand Up @@ -154,6 +186,7 @@ def __init__(self, filename: str, content: str, warning_name: str) -> None:
Linter.get_disabled_enabled_boundaries(self.lines, warning_name)
)
self.sorted_replacements: "Optional[list[Replacement]]" = None
self.has_suppressed_agent_instructions: bool = False

def add_warning(self, span: Span, msg: str) -> LintWarning:
w = LintWarning(span, msg)
Expand Down Expand Up @@ -254,6 +287,11 @@ def print_warnings(self, args: "argparse.Namespace") -> None:
verbose,
)

if warning.agent_instructions is not None:
self.print_agent_instructions(args, warning.agent_instructions)

self.print_suppressed_agent_instrutions()

def _print_highlighted_code(
self,
span: Span,
Expand Down Expand Up @@ -432,6 +470,66 @@ def sort_and_check_replacements_for_conflicts(self) -> None:
else:
break

@classmethod
def format_agent_instructions(cls, instructions: str) -> list[str]:
MAX_LINE_LENGTH = 79

lines: list[str] = []
for paragraph in filter(
lambda p: p != "", re.split(r"\n\n+", instructions)
):
if lines:
lines.append("")
if paragraph.startswith(" "):
for line in paragraph.split("\n"):
assert line.startswith(" "), (
"Do not mix formatted and unformatted lines in a "
"paragraph"
)
lines.append(f" {line}")
else:
assert "\n " not in paragraph, (
"Do not mix formatted and unformatted lines in a paragraph"
)
line = ""
sep = ""
for word in filter(
lambda w: w != "", re.split(r"[ \n]", paragraph)
):
assert len(word) <= MAX_LINE_LENGTH - 2, (
f'Word "{word}" is too long'
)
if len(f" {line}{sep}{word}") > MAX_LINE_LENGTH:
lines.append(f" {line}")
line = f"{word}"
else:
line += f"{sep}{word}"
sep = " "
if line != "":
lines.append(f" {line}")
return lines

def _print_agent_instructions(self, instructions: str) -> None:
self.console.print("[bold]instructions for agents:[/bold]")
for line in self.format_agent_instructions(instructions):
self.console.print(escape(line))
self.console.print()

def print_agent_instructions(
self, args: "argparse.Namespace", instructions: "Callable[[], str]"
) -> None:
if self.verbose_output(args, 2):
self._print_agent_instructions(instructions())
else:
self.has_suppressed_agent_instructions = True

def print_suppressed_agent_instrutions(self) -> None:
if self.has_suppressed_agent_instructions:
self._print_agent_instructions(
"Run this tool again with AI_AGENT=1 or -vv to see detailed "
"instructions for agents."
)


class ExecutionContext(contextlib.AbstractContextManager):
def __init__(self, warning_name: str, args: argparse.Namespace) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def is_agent() -> bool:

@_agent_test
def is_codex() -> bool:
return os.getenv("CODEX_CLI") == "1"
return os.getenv("CODEX_CI") == "1"


def is_agent_environment() -> bool:
Expand Down
Loading
Loading