Skip to content
Merged
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

README truth check for developer onboarding.

Awal is a small local CLI that scans the README and compares it with the repo surface to ensure the first-run path works: missing package scripts, npm/yarn drift, missing compose files, stale env files, and broken Docker commands.
Awal is a small local CLI that checks whether a repo's README still matches the repo itself. It catches missing package scripts, package-manager drift, missing compose files, stale env examples, broken Docker notes, missing local ports, and setup commands that no fresh developer can actually run.

No command execution. No network calls. No cloud upload. No LLM.

Expand Down Expand Up @@ -78,7 +78,7 @@ Open `http://127.0.0.1:8774/` to type a local repo path and scan it.

## GitHub Action

Use Awal as a PR gate:
Use Awal as a PR gate. Pin a release tag when one exists; while testing the repo directly, use `main`:

```yaml
name: Awal
Expand All @@ -90,7 +90,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mara-org/awal@v0
- uses: mara-org/awal@main
with:
path: .
fail-on: high
Expand All @@ -110,4 +110,6 @@ Awal does not run your README commands. It statically checks whether the command

## About

Maintained by [mara](https://github.com/mara-org). Created by the CTO.
Maintained by [Mara](https://github.com/mara-org).

Created by [@gqnxx](https://github.com/gqnxx).
27 changes: 23 additions & 4 deletions src/awal/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
re.compile(r"\bprocess\.env\[['\"]([A-Z][A-Z0-9_]{2,})['\"]\]"),
re.compile(r"\bimport\.meta\.env\.([A-Z][A-Z0-9_]{2,})"),
re.compile(r"\bDeno\.env\.get\(['\"]([A-Z][A-Z0-9_]{2,})['\"]\)"),
re.compile(r"\bos\.environ(?:\.get)?\(['\"]([A-Z][A-Z0-9_]{2,})['\"]\)"),
re.compile(r"\bos\.environ\[['\"]([A-Z][A-Z0-9_]{2,})['\"]\]"),
re.compile(r"\bos\.environ\.get\(['\"]([A-Z][A-Z0-9_]{2,})['\"]\)"),
re.compile(r"\bos\.getenv\(['\"]([A-Z][A-Z0-9_]{2,})['\"]\)"),
re.compile(r"\bENV\[['\"]([A-Z][A-Z0-9_]{2,})['\"]\]"),
)
Expand All @@ -95,6 +96,7 @@ class RepoSurface:
readme_text: str
commands: tuple[ReadmeCommand, ...]
package_scripts: dict[str, str]
has_package_json: bool
package_manager: str | None
has_pyproject: bool
has_setup_py: bool
Expand Down Expand Up @@ -160,6 +162,7 @@ def inspect_repo(root: Path) -> RepoSurface:
readme_text=readme_text,
commands=tuple(extract_commands(readme_text)),
package_scripts=package_scripts,
has_package_json=(root / "package.json").exists(),
package_manager=detect_package_manager(root),
has_pyproject=(root / "pyproject.toml").exists(),
has_setup_py=(root / "setup.py").exists(),
Expand Down Expand Up @@ -193,9 +196,22 @@ def command_findings(surface: RepoSurface) -> list[Finding]:
)
findings.extend(cd_findings(surface, text, command.line))
script = package_script_from_command(text)
if script is not None and surface.package_scripts:
if script is not None:
script_name, display = script
if script_name not in surface.package_scripts:
if not surface.has_package_json:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve package.json relative to command directory

This new missing_package_manifest check assumes every npm/pnpm/yarn script command should be backed by a root-level package.json, which creates false high-severity blocks for valid README flows that first cd into a subdirectory (for example, cd web then npm run dev with web/package.json). I verified this in the current code path: the scanner reports missing_package_manifest even when the script exists in the target subproject, so monorepo or multi-package repos are now incorrectly flagged as broken.

Useful? React with 👍 / 👎.

findings.append(
Finding(
file=readme_file(surface),
line=command.line,
severity="high",
category="missing_package_manifest",
excerpt=display,
why_it_matters="The README asks new developers to run a package script, but the repo has no package.json.",
suggested_review="Add package.json with the documented script or fix the README command.",
rule_id="missing_package_manifest",
)
)
elif script_name not in surface.package_scripts:
findings.append(
Finding(
file=readme_file(surface),
Expand Down Expand Up @@ -497,10 +513,13 @@ def detect_package_manager(root: Path) -> str | None:

def package_script_from_command(command: str) -> tuple[str, str] | None:
match = re.search(r"\b(?:npm|pnpm|bun)\s+run\s+([A-Za-z0-9:_-]+)\b", command)
if match:
return match.group(1), match.group(0)
match = re.search(r"\byarn\s+run\s+([A-Za-z0-9:_-]+)\b", command)
if match:
return match.group(1), match.group(0)
match = re.search(r"\byarn\s+([A-Za-z0-9:_-]+)\b", command)
if match and match.group(1) not in {"add", "install", "global", "dlx"}:
if match and match.group(1) not in {"add", "install", "global", "dlx", "run"}:
return match.group(1), match.group(0)
if re.search(r"\bnpm\s+start\b", command):
return "start", "npm start"
Expand Down
35 changes: 35 additions & 0 deletions tests/test_awal.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,41 @@ def test_undocumented_env_var_blocks(self):
self.assertEqual(report.status, "block")
self.assertIn("undocumented_env_var", {finding.category for finding in report.findings})

def test_python_os_environ_subscript_counts_as_env_usage(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "README.md").write_text("```bash\npython app.py\n```", encoding="utf-8")
(root / ".env.example").write_text("DATABASE_URL=postgres://localhost/app\n", encoding="utf-8")
(root / "requirements.txt").write_text("flask\n", encoding="utf-8")
(root / "app.py").write_text('import os\nprint(os.environ["API_TOKEN"])\n', encoding="utf-8")

report = scan_path(root)

self.assertEqual(report.status, "block")
self.assertIn("undocumented_env_var", {finding.category for finding in report.findings})

def test_missing_package_json_still_blocks_documented_script(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "README.md").write_text("```bash\nnpm run dev\n```", encoding="utf-8")

report = scan_path(root)

self.assertEqual(report.status, "block")
self.assertIn("missing_package_manifest", {finding.category for finding in report.findings})

def test_yarn_run_reads_actual_script_name(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "README.md").write_text("```bash\nyarn run dev\n```", encoding="utf-8")
(root / "package.json").write_text('{"scripts":{"dev":"vite"}}', encoding="utf-8")
(root / "yarn.lock").write_text("", encoding="utf-8")

report = scan_path(root)

self.assertNotIn("missing_package_script", {finding.category for finding in report.findings})
self.assertNotIn("missing_package_manifest", {finding.category for finding in report.findings})

def test_env_example_secret_is_critical(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
Expand Down
Loading