From 960b3d9fa925c9ad2ca6e98fcc73b6e888174a35 Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:20:54 +0800 Subject: [PATCH] Fix context-aware path reference false positives --- CHANGELOG.md | 9 +- README.md | 5 +- benchmarks/prospective-review-v1/README.md | 6 +- .../prospective-review-v1/context/README.md | 3 + .../development-reuse.json | 39 ++++ .../human-pilot/README.md | 13 +- .../human-pilot/status.json | 12 ++ .../tool-probe/README.md | 15 +- .../tool-probe/development/README.md | 51 ++++++ .../tool-probe/development/report.json | 129 ++++++++++++++ docs/README.md | 1 + docs/limitations.md | 27 ++- docs/review-comparison.md | 9 +- docs/simplified/limitations.md | 10 +- docs/traditional/limitations.md | 10 +- docs/translations.md | 6 + scripts/run_path_development_check.py | 75 ++++++++ src/agent_config_score/scanner.py | 70 +++++++- tests/test_human_review_packet.py | 9 + tests/test_path_development_check.py | 87 +++++++++ tests/test_path_references.py | 168 ++++++++++++++++++ 21 files changed, 736 insertions(+), 18 deletions(-) create mode 100644 benchmarks/prospective-review-v1/development-reuse.json create mode 100644 benchmarks/prospective-review-v1/human-pilot/status.json create mode 100644 benchmarks/prospective-review-v1/tool-probe/development/README.md create mode 100644 benchmarks/prospective-review-v1/tool-probe/development/report.json create mode 100644 scripts/run_path_development_check.py create mode 100644 tests/test_path_development_check.py create mode 100644 tests/test_path_references.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bcb88..f21b145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,18 @@ - Optional Simplified/Traditional Chinese repository overviews, core user guides and limitation summaries, with reciprocal language links and an explicit source-revision/coverage note. Advanced references remain English; CLI messages, JSON fields, rule semantics and the published v0.23.0 distributions are unchanged. - An actual unscored Next.js source probe replayed on Linux with the frozen 0.22.0 code, complete Git-object/source verification and separate native/root scopes. Earlier failures, the interrupted new transfer and Windows path-case mismatch remain recorded; no correctness metrics, human labels or 0.23.0-case validation are implied. -- A readable 28-line human preannotation page with unchanged source bytes, license/provenance links, input limitations and a plain-language answer format. Consent and preparation are not completed annotations; the parent corpus remains unresolved and product rules are unchanged. +- A readable 28-line human preannotation page with unchanged source bytes, license/provenance links, input limitations and a plain-language answer format. Consent and preparation are not completed annotations; the parent corpus remains unresolved and the page itself does not change product rules. +- A separate offline current-code path development check with complete pinned-source verification, explicit contamination disclosure and retained unscored findings. The eight path false-positive signals are gone while the context-size warning remains; this is not human feedback, independent accuracy or validation of the published v0.23.0 wheel. ### Changed - Name Chinese documentation paths by script (`simplified` and `traditional`) rather than region, updating all language selectors and internal links. Runtime behavior and published v0.23.0 artifacts are unchanged. +- Defer the volunteer human annotation without inventing a replacement label. Disclose Next.js as development-reused after its findings informed path fixes; preserve original collection metadata, protocols, AI response and frozen report as historical artifacts, not current holdout evidence. + +### Fixed + +- Avoid `dead-path` false positives for narrowly typed repository/remote slugs and immediate closed `(if exists)` prose references, without exempting other local paths, actual Markdown links or dangerous commands. +- Keep real package-relative source paths beneath `build`/`dist`/`target`/`.next` in the existence index while preserving instruction-discovery exclusions. Prune dependency/admin trees before indexing and do not follow directory symlinks or index external/broken link entries. English, Simplified and Traditional limitation notes stay aligned. ## v0.23.0 diff --git a/README.md b/README.md index 4504bc2..c3c7e2f 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,10 @@ separate native/root instruction scopes, using the frozen 0.22.0 tool code. Its findings are unscored observations, not verified defects. Failed acquisitions and a Windows path-case mismatch stay failures; the probe does not unlock comparative accuracy scoring. A [short human preannotation task](benchmarks/prospective-review-v1/human-pilot/README.md) -is prepared separately; agreement to review is not a completed annotation. +is retained separately but human review is deferred; no AI answer replaces a +human annotation. Next.js findings now inform [path-rule development](benchmarks/prospective-review-v1/development-reuse.json), +so that case is not an independent holdout. Original research artifacts remain +unchanged; development checks do not establish comparative accuracy. ## Share a real-world case safely diff --git a/benchmarks/prospective-review-v1/README.md b/benchmarks/prospective-review-v1/README.md index cfff821..a9628ce 100644 --- a/benchmarks/prospective-review-v1/README.md +++ b/benchmarks/prospective-review-v1/README.md @@ -16,7 +16,11 @@ with a complete verified Next.js snapshot and unscored findings; its larger instruction/path evidence is not a matched comparison. A [short human preannotation page](human-pilot/review.md) is prepared for a different retained case, with no tool/model conclusion shown. No human answer or reference -label is inferred from agreement to review it. +label is inferred from agreement to review it. Human review is now deferred, +with no AI replacement label. A later [development-reuse record](development-reuse.json) +marks the Next.js case as exposed and used to fix three path false-positive +mechanisms. Frozen collection-time `used_for_rule_development: false` metadata +is historical, not a current holdout claim; use fresh inputs for a future study. The [registered protocol](protocol.json) was written before downloading the instruction contents or inspecting predictions. It names twelve repositories; diff --git a/benchmarks/prospective-review-v1/context/README.md b/benchmarks/prospective-review-v1/context/README.md index 53dea0c..c5406a7 100644 --- a/benchmarks/prospective-review-v1/context/README.md +++ b/benchmarks/prospective-review-v1/context/README.md @@ -5,6 +5,9 @@ or accuracy measurements.** All five parent cases remain present and unresolved. The original root instruction text and pinned revisions are unchanged. A separate [one-case AI pilot](../pilot/README.md) records an actual unscored model response. It does not change this capture's unresolved reference labels. +The Next.js findings later informed [path-rule development](../development-reuse.json). +That case is now development-reused, not an independent holdout; the original +corpus and collection-time metadata remain frozen for audit. This capture adds 27 complete text documents and 619 reference records backed by five nontruncated Git trees containing 97,303 entries in total. These are evidence diff --git a/benchmarks/prospective-review-v1/development-reuse.json b/benchmarks/prospective-review-v1/development-reuse.json new file mode 100644 index 0000000..c885ff4 --- /dev/null +++ b/benchmarks/prospective-review-v1/development-reuse.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "recorded_date": "2026-10-11", + "status": "development_reuse_not_independent_holdout", + "selected_case_id": "vercel--next.js", + "repository": "vercel/next.js", + "source_revision": "ba80ee48fc319735151c3ad6d9bb9a8180c9f09e", + "base_product_revision": "38bc911273b449e4a23287540767c9ca70108c65", + "frozen_context_corpus_sha256": "2f6fbbc6d77c43e688ea0d6020cf8a74232faf6ce2eaeee140eb27015dfb8b7d", + "frozen_tool_report_sha256": "19d4adeedb8e5b5d2baf9436d881f54f035bbd3f31479aeb23f9400b0d418104", + "frozen_protocol_sha256": "f074e2c32187f2d9c44329cf07d7ff6ebbeb0220f11a665dd16c163c0d0cb0ff", + "author": "AI coding assistant; development analysis, not independent human annotation", + "influenced_rule": "dead-path", + "mechanisms": [ + { + "instruction_lines": [30], + "observation": "The instruction uses a package-relative src/build/index.ts reference. packages/next/src/build/index.ts is present in the complete pinned Git tree, but the filesystem suffix index excluded every build directory.", + "change": "Separate instruction-discovery exclusions from path-index exclusions; retain existing build/dist/target/.next names for existence lookup without reading their contents or expanding instruction discovery. Prune dependencies/admin trees and do not follow directory symlinks." + }, + { + "instruction_lines": [49], + "observation": "turbopack/crates/README.md is absent, but its immediate closed '(if exists)' condition does not assert existence.", + "change": "Skip only the marked plain-prose path candidate; actual Markdown link destinations and other required paths remain checked." + }, + { + "instruction_lines": [272, 275, 285, 292, 298, 332], + "observation": "vercel/next.js denotes a GitHub repository or remote in explicit repository/PR/branch contexts, not a local JavaScript file.", + "change": "Recognize narrow same-line repository-role grammar for two-component slugs; unrelated local paths and command/credential checks remain independent." + } + ], + "unchanged_artifacts": "Original corpora, labels, protocols, model response, failures and frozen tool report remain unchanged. Their used_for_rule_development=false fields describe collection time only; this overlay supersedes that status for the Next.js case after reuse.", + "future_evaluation_boundary": "Do not use this exposed case as independent held-out evidence for these fixes. Fresh inputs, matched scope and independent adjudication are still required for a formal accuracy comparison.", + "annotations": [], + "human_reference_status": "unreviewed", + "comparison_ready": false, + "correctness_metrics": null, + "maintainer_contact": "none", + "new_model_runs": "none" +} diff --git a/benchmarks/prospective-review-v1/human-pilot/README.md b/benchmarks/prospective-review-v1/human-pilot/README.md index c164fb2..1d76e2d 100644 --- a/benchmarks/prospective-review-v1/human-pilot/README.md +++ b/benchmarks/prospective-review-v1/human-pilot/README.md @@ -1,8 +1,11 @@ # One short human preannotation task -**Status: prepared, awaiting an actual annotation.** The user agreed to read one -short sample; no decision, rationale, reviewer history or correctness result is -supplied by that agreement. No second human or model has been invented. +**Current status: deferred, no human annotation.** The volunteer reviewer is +unavailable; no answer is required to continue product maintenance. The separate +[current status](status.json) preserves this update without rewriting the +preparation protocol's historical `awaiting_annotation` state. No decision, +rationale, reviewer history or correctness result is supplied by prior agreement +to review. An AI answer is not substituted; no second human or model is invented. [Read the review page](review.md). It presents the full 28-line source, the review-needed rubric, missing-context boundaries and a three-part plain-language @@ -31,7 +34,9 @@ an existing input for readability; it is not a new independent case. Tests bind the displayed source bytes to that corpus, its canonical one-case packet and the recorded selection. They test preparation integrity, not human review completion or whether an eventual answer is correct. The parent corpus, -existing Next.js pilot, frozen tool protocol and product rules are unchanged. +existing Next.js pilot and frozen tool protocol remain unchanged. Next.js has +subsequently been reused for [path-rule development](../development-reuse.json); +this readable scikit-learn packet is not that development case. ## Evidence not yet supplied diff --git a/benchmarks/prospective-review-v1/human-pilot/status.json b/benchmarks/prospective-review-v1/human-pilot/status.json new file mode 100644 index 0000000..143e44a --- /dev/null +++ b/benchmarks/prospective-review-v1/human-pilot/status.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "updated_date": "2026-10-11", + "annotation_status": "deferred_no_human_annotation", + "reason": "The volunteer reviewer is unavailable. Do not require an answer or substitute an AI answer for human annotation.", + "preparation_protocol": "protocol.json", + "preparation_protocol_status_is_historical": true, + "annotations": [], + "human_reference_status": "unreviewed", + "comparison_ready": false, + "correctness_metrics": null +} diff --git a/benchmarks/prospective-review-v1/tool-probe/README.md b/benchmarks/prospective-review-v1/tool-probe/README.md index fa53fdc..3f59c5a 100644 --- a/benchmarks/prospective-review-v1/tool-probe/README.md +++ b/benchmarks/prospective-review-v1/tool-probe/README.md @@ -9,6 +9,16 @@ and records transport/environment repair. The original seven stages in [failures.json](failures.json) remain unchanged; new failures are also retained. No model comparison, reference label or correctness score is supplied. +**Subsequent development reuse (2026-10-11):** these findings informed path-rule +fixes after the frozen replay. The [reuse record](../development-reuse.json) +discloses three false-positive mechanisms at eight instruction lines: a real +package-relative source path beneath `build`, an explicitly conditional path, +and repository slugs mistaken for local files. This is AI-authored maintenance +analysis, not an independent human reference label. The original corpus, +protocol and report remain frozen. A [separate development check](development/README.md) +uses current code; neither run establishes real-world accuracy. Do not use this +case as a future independent holdout for the resulting fixes. + ## Retained observation, not confirmed defects The 51,988,413-byte archive matched all 50,749 tracked entries: 33,876 regular @@ -36,9 +46,10 @@ new native Linux directory satisfied the unchanged evidence checks. Neither the path facts, source names nor scanner rules were loosened to obtain a result. Host-dependent behavior matters; this is not an OS-invariant defect judgment. -This research workflow checks the filesystem/scope part of evaluation. It does +The original research workflow checks the filesystem/scope part of evaluation. It does not adjudicate the previous AI answer, compute correctness metrics, or tune any -scanner rule. Only the previously selected Next.js case is in scope; the five +scanner rule. Later tuning is disclosed above rather than retroactively changing +that replay. Only the previously selected Next.js case is in scope; the five parent cases and the original AI packet/response remain unchanged. ## Source integrity before findings diff --git a/benchmarks/prospective-review-v1/tool-probe/development/README.md b/benchmarks/prospective-review-v1/tool-probe/development/README.md new file mode 100644 index 0000000..fc3837c --- /dev/null +++ b/benchmarks/prospective-review-v1/tool-probe/development/README.md @@ -0,0 +1,51 @@ +# Path-reference development check + +This is **development-reused, AI-authored maintenance evidence**, not an +independent holdout, human annotation, real user feedback or AI comparison. +The [reuse record](../../development-reuse.json) identifies the eight instruction +lines and three mechanisms that informed the fixes. Original inputs, reference +labels, model response and [frozen tool report](../report.json) remain unchanged. + +The current-code [report](report.json) verifies the same complete pinned Next.js +archive and supplied context/path facts on Linux. Source commands, dependencies, +hooks and tests are never executed. Native discovery and research-only root +scope remain separate; the source path index is still larger than the AI packet. +The tool and development script have recorded source fingerprints. Package +version `0.23.0` alone does not identify this **unreleased** rule state; this is +not a scan with the immutable published v0.23.0 wheel. + +| Scope | Files | Frozen findings | Development findings | +|---|---:|---:|---:| +| Native discovery | 27 | 8 `dead-path`, 1 `context-large` | 1 `context-large` | +| Research-only root | 1 | 8 `dead-path`, 1 `context-large` | 1 `context-large` | + +The source was not improved between these runs; the checker removed its own +path false positives. The heuristic score changes from C 76 to A 94, but that is +**not an increase in instruction quality or a measured accuracy improvement**. +The independent reference remains unresolved, `comparison_ready` stays false, +correctness metrics remain null, and the earlier AI cleanup concern is not +adjudicated by these fixes. There is no new model or hybrid run. + +## Offline reproduction + +From a current source checkout, with `src` on `PYTHONPATH`, on a case-sensitive +filesystem compatible with the retained path facts: + +```bash +python scripts/run_path_development_check.py \ + --archive /absolute/path/to/pinned-source.tar.gz \ + --output-dir /absolute/path/to/new-development-check +``` + +The archive must have SHA-256 +`2d5323129612f58c16523d29e09032518207ee2743467c466e526fc5a26650b5`; +all tracked Git objects and supplied path facts are additionally checked. No +download is performed. A new output directory is required. A failed preparation +has no completed report and is not a clean prediction. + +The helper applies the frozen source/scope/safety policy with **only the product +fingerprint replaced in memory** to verify current code. That explicitly derived +policy is not the frozen or preregistered tool. Its fingerprint and derivation +are retained separately. Never rewrite original protocols or present this +development case as untouched evaluation evidence. Future independent accuracy +work still needs new cases, matched scopes and independently adjudicated labels. diff --git a/benchmarks/prospective-review-v1/tool-probe/development/report.json b/benchmarks/prospective-review-v1/tool-probe/development/report.json new file mode 100644 index 0000000..f032d81 --- /dev/null +++ b/benchmarks/prospective-review-v1/tool-probe/development/report.json @@ -0,0 +1,129 @@ +{ + "schema_version": 1, + "status": "development_check_not_independent_evaluation", + "completed_at_utc": "2026-10-11T08:16:34.833879+00:00", + "selected_case_id": "vercel--next.js", + "repository": "vercel/next.js", + "source_revision": "ba80ee48fc319735151c3ad6d9bb9a8180c9f09e", + "development_change_base_revision": "38bc911273b449e4a23287540767c9ca70108c65", + "product_state": "current source checkout, identified by tool fingerprint; package version alone does not identify rule state", + "package_version": "0.23.0", + "python_version": "3.12.3", + "host_platform": "Linux", + "tool_source_sha256": "8ebdd46fb756d03335e106ffd1ae4a5a334f0e4c07a05c8ae2397dd00fb99be1", + "development_script_sha256": "8a546f2d098b354bc430fa7d579efca0ce82ca76339359db2a526cd7cae8f0ca", + "frozen_protocol_sha256": "f074e2c32187f2d9c44329cf07d7ff6ebbeb0220f11a665dd16c163c0d0cb0ff", + "development_policy_sha256": "c8d9c5a3978379ccd544ce20ebf3cd9e50050ee05edad8db5067e10d534a6676", + "policy_derivation": "Same source, scopes and safety limits as frozen protocol; only tool_source_sha256 replaced for current rules in memory. No frozen artifact was rewritten.", + "development_reuse_sha256": "6fb025cb9c6c15faf8437f393a2763bfe7bd733c58dcf9edec2aa829f9929a9a", + "context_corpus_sha256": "2f6fbbc6d77c43e688ea0d6020cf8a74232faf6ce2eaeee140eb27015dfb8b7d", + "packet_sha256": "12a6dadbf07760b8c13b2f17456bf8117e8ce6176ace82e444cfc1be48939b2e", + "archive": { + "input_kind": "local_archive_replay", + "archive_sha256": "2d5323129612f58c16523d29e09032518207ee2743467c466e526fc5a26650b5", + "archive_bytes": 51988413, + "transport_provenance": "not_authenticated_by_replay; pinned Git objects are verified independently" + }, + "snapshot": { + "status": "verified_tracked_source_snapshot", + "retained_tree_response_sha": "ba80ee48fc319735151c3ad6d9bb9a8180c9f09e", + "tree_snapshot_sha256": "082ca7018fc61d3a5df1d3be93d4f1ad0d572547eb91529afc886e9373662714", + "tree_entries": 50749, + "regular_files": 33876, + "directories": 16831, + "symlinks": 42, + "verified_blob_bytes": 158608765, + "symlink_materialization": "original relative targets with host-native separators", + "git_metadata": "not_created", + "executable_permissions": "not_preserved", + "generated_ignored_untracked_files": "not_supplied", + "canonical_tree_object_sha": "fb2da1887f2c58ad1da0cf30ae92e6e0219ed709" + }, + "used_for_rule_development": true, + "input_verification": { + "supplied_files_verified": 11, + "physical_path_facts_checked": 420, + "unknown_path_facts_not_asserted": 36 + }, + "native_discovery": { + "files": [ + ".github/AGENTS.md", + "AGENTS.md", + "apps/bundle-analyzer/AGENTS.md", + "evals/AGENTS.md", + "evals/next-upgrade/evals/future-cache-components-nudge/AGENTS.md", + "evals/next-upgrade/evals/future-cache-components-nudge/CLAUDE.md", + "evals/next-upgrade/evals/future-cache-components-same-version/AGENTS.md", + "evals/next-upgrade/evals/future-cache-components-same-version/CLAUDE.md", + "evals/next-upgrade/evals/future-cache-components/AGENTS.md", + "evals/next-upgrade/evals/future-cache-components/CLAUDE.md", + "evals/next-upgrade/evals/latest-cross-major/AGENTS.md", + "evals/next-upgrade/evals/latest-cross-major/CLAUDE.md", + "evals/next-upgrade/evals/latest-nudge/AGENTS.md", + "evals/next-upgrade/evals/latest-nudge/CLAUDE.md", + "evals/next-upgrade/evals/latest-same-major/AGENTS.md", + "evals/next-upgrade/evals/latest-same-major/CLAUDE.md", + "evals/next-upgrade/evals/security-cross-major/AGENTS.md", + "evals/next-upgrade/evals/security-cross-major/CLAUDE.md", + "evals/next-upgrade/evals/security-duplicate/AGENTS.md", + "evals/next-upgrade/evals/security-duplicate/CLAUDE.md", + "evals/next-upgrade/evals/security-nudge-original-task/AGENTS.md", + "evals/next-upgrade/evals/security-nudge-original-task/CLAUDE.md", + "evals/next-upgrade/evals/security-same-major/AGENTS.md", + "evals/next-upgrade/evals/security-same-major/CLAUDE.md", + "packages/next/AGENTS.md", + "test/AGENTS.md", + "turbopack/AGENTS.md" + ], + "score": 94, + "grade": "A", + "estimated_tokens": 8911, + "duplicate_ratio": 0.0267, + "findings": [ + { + "severity": "warning", + "code": "context-large", + "message": "Persistent instruction context is large", + "file": "AGENTS.md", + "line": null, + "penalty": 6 + } + ], + "suppressed_findings": [], + "review_needed": true, + "score_meaning": "lint heuristic, not correctness" + }, + "root_instruction_scope": { + "files": [ + "AGENTS.md" + ], + "score": 94, + "grade": "A", + "estimated_tokens": 7156, + "duplicate_ratio": 0.0, + "findings": [ + { + "severity": "warning", + "code": "context-large", + "message": "Persistent instruction context is large", + "file": "AGENTS.md", + "line": null, + "penalty": 6 + } + ], + "suppressed_findings": [], + "review_needed": true, + "score_meaning": "lint heuristic, not correctness" + }, + "comparison_ready": false, + "correctness_metrics": null, + "human_reference_status": "unreviewed", + "ai_status": "separate_existing_pilot_not_imported", + "hybrid_status": "not_run", + "remaining_parity_gaps": [ + "Native discovery may include instruction text not supplied to the AI packet.", + "The root-scoped run controls discovery, but path checks use the full physical snapshot/index; the AI received bounded path facts only.", + "Referenced documents are physically present; the scanner does not semantically review their text unless discovery selects them.", + "No independent human reference annotation or matched comparative model batch exists." + ] +} diff --git a/docs/README.md b/docs/README.md index 867ed99..ccf4087 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ read the result, and troubleshoot a failing gate. ## Understanding findings - [Score contract and limitations](limitations.md) +- [Path-reference grammar and limits](limitations.md#plain-prose-path-references) - [Instruction context and prohibition scopes](instruction-context.md) - [False success-reporting checks](verification-reporting.md) - [Bulk environment-transfer checks](environment-upload.md) diff --git a/docs/limitations.md b/docs/limitations.md index 236195d..3ad0dc7 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -81,7 +81,32 @@ URL-encoded spaces and angle-bracket destinations are supported. Remote links, anchor-only links, fenced examples, absolute paths, and paths escaping the repository are skipped. Reference-style links and nested parentheses in link destinations are not currently parsed. This is a limited link check, not a full -Markdown validator. Plain prose path heuristics retain their existing behavior. +Markdown validator. + +## Plain prose path references + +File-like prose references are heuristic, not a complete natural-language path +resolver. An immediate, closed `(if exists)` or `(if it exists)` condition makes +only that prose reference optional; Markdown link destinations remain checked. +An explanatory suffix after `-` or `:` inside that condition is supported. +Unclosed conditions, distant optionality words and unrelated paths on the same +line do not receive this exemption. + +Two-component slugs such as `owner/tool.js` are excluded only in narrow, +same-line repository roles: followed by `repository`/`remote` and punctuation, +or after `merge target is`, `fork of`, `re-pushed to a branch in`, or +`CI trusts branches inside`. A repeated same-slug `fork of ... and not ... itself` +form is also recognized. Merely mentioning GitHub or a repository does not +exempt local paths. Other paraphrases and languages may still be misclassified. + +Package-relative suffix lookup includes existing paths under `build`, `dist`, +`target` and `.next`; instruction discovery still skips those trees. Dependency +and administration trees remain excluded from the suffix index, directory +symlinks are not traversed, and external/broken symlink entries are excluded. +Existence does not establish that a file is authoritative, generated correctly, +or intended by an ambiguous reference. Generated/untracked files not present in +the checkout can still be reported missing; filesystem case behavior remains +host-dependent. These exemptions do not disable command or credential checks. ## Red-team audit diff --git a/docs/review-comparison.md b/docs/review-comparison.md index 173dc8f..bdc5cb9 100644 --- a/docs/review-comparison.md +++ b/docs/review-comparison.md @@ -37,7 +37,14 @@ A [short human review page](../benchmarks/prospective-review-v1/human-pilot/revi presents one unchanged 28-line candidate without tool/model conclusions and accepts a plain-language decision, rationale and prior-exposure note. This is a single-reviewer preannotation task, not an adjudicated holdout. Consent is not a -reference label, and no completed annotation is currently retained. +reference label, and no completed annotation is currently retained. Human review +is now deferred; product maintenance can continue without fabricating labels. + +The Next.js probe subsequently informed three path false-positive fixes. Its +[development-reuse record](../benchmarks/prospective-review-v1/development-reuse.json) +supersedes collection-time non-development status for that case, not the frozen +artifacts themselves. Current-code replays are development checks, not validation +on untouched inputs. A future independent study requires fresh cases. ## Run the local baseline diff --git a/docs/simplified/limitations.md b/docs/simplified/limitations.md index 23d777b..7c92068 100644 --- a/docs/simplified/limitations.md +++ b/docs/simplified/limitations.md @@ -38,7 +38,15 @@ AgentConfigScore 的分数衡量已经检测到的、由规则定义的风险, ## 本地 Markdown 链接 -简单的行内链接、图片目标相对于指令文件目录检查。URL 编码空格和尖括号目标受支持;只检查文件是否存在,不检查片段对应的标题。远程链接、纯锚点、代码块示例、绝对路径和逃出仓库的路径会跳过。引用式链接及目标中嵌套括号暂不解析,普通散文路径启发式保持原有行为。这不是完整 Markdown 校验器。 +简单的行内链接、图片目标相对于指令文件目录检查。URL 编码空格和尖括号目标受支持;只检查文件是否存在,不检查片段对应的标题。远程链接、纯锚点、代码块示例、绝对路径和逃出仓库的路径会跳过。引用式链接及目标中嵌套括号暂不解析。这不是完整 Markdown 校验器。 + +## 普通散文中的路径 + +只在紧邻路径且闭合的 `(if exists)`、`(if it exists)` 条件下,把对应散文路径视为可选;括号内 `-` 或 `:` 后可带简短解释。真正的 Markdown 链接仍检查目标。同一行的其他路径、远处的“可选”字样、不闭合的条件不会因此豁免。 + +`owner/tool.js` 这类两段名称,只在明确的同一行仓库角色语法中跳过:后接 `repository`/`remote` 及标点,或前接 `merge target is`、`fork of`、`re-pushed to a branch in`、`CI trusts branches inside`;也识别相同仓库名的 `fork of ... and not ... itself` 形式。仅提到 GitHub 或仓库,不会让本地路径免检。其他改写和语言仍可能误判。[完整英文边界](../limitations.md#plain-prose-path-references)。 + +包相对路径的后缀索引包含 `build`、`dist`、`target`、`.next` 中已经存在的路径,但不会因此扩大指令文件的发现范围。依赖、管理目录仍排除在后缀索引外;不遍历目录符号链接,也不索引外部或失效链接。存在不代表内容正确或路径意图明确;未生成的文件仍可能被报告缺失,大小写判断依赖宿主文件系统。这些豁免不会关闭危险命令或凭据检查。 ## 回归保护的具体范围 diff --git a/docs/traditional/limitations.md b/docs/traditional/limitations.md index 05ddd97..005b7c0 100644 --- a/docs/traditional/limitations.md +++ b/docs/traditional/limitations.md @@ -38,7 +38,15 @@ AgentConfigScore 的分數衡量已偵測到、由規則定義的風險,不衡 ## 本機 Markdown 連結 -簡單的行內連結、圖片目標依指令檔目錄檢查。支援 URL 編碼空格與尖括號目標;只檢查檔案是否存在,不檢查片段對應的標題。遠端連結、純錨點、程式碼區塊範例、絕對路徑與離開儲存庫的路徑會略過。參照式連結及目標中巢狀括號暫不解析,一般敘述路徑的啟發式維持原有行為。這不是完整 Markdown 驗證工具。 +簡單的行內連結、圖片目標依指令檔目錄檢查。支援 URL 編碼空格與尖括號目標;只檢查檔案是否存在,不檢查片段對應的標題。遠端連結、純錨點、程式碼區塊範例、絕對路徑與離開儲存庫的路徑會略過。參照式連結及目標中巢狀括號暫不解析。這不是完整 Markdown 驗證工具。 + +## 一般敘述中的路徑 + +只在緊鄰路徑且閉合的 `(if exists)`、`(if it exists)` 條件下,將對應敘述路徑視為選用;括號內 `-` 或 `:` 後可附簡短說明。實際的 Markdown 連結仍檢查目標。同一行的其他路徑、遠處的「選用」字樣、未閉合條件不會因此豁免。 + +`owner/tool.js` 這類兩段名稱,只在明確的同一行儲存庫角色語法中略過:後接 `repository`/`remote` 及標點,或前接 `merge target is`、`fork of`、`re-pushed to a branch in`、`CI trusts branches inside`;也識別相同儲存庫名稱的 `fork of ... and not ... itself` 形式。僅提到 GitHub 或儲存庫,不會讓本機路徑免檢。其他改寫及語言仍可能誤判。[完整英文界線](../limitations.md#plain-prose-path-references)。 + +套件相對路徑的後綴索引包含 `build`、`dist`、`target`、`.next` 中已存在的路徑,但不會因此擴大指令檔的探索範圍。相依套件、管理目錄仍排除於後綴索引之外;不遍歷目錄符號連結,也不索引外部或失效連結。存在不代表內容正確或路徑意圖明確;未產生的檔案仍可能被回報缺失,大小寫判斷依賴宿主檔案系統。這些豁免不會關閉危險命令或認證資訊檢查。 ## 回歸保護的具體範圍 diff --git a/docs/translations.md b/docs/translations.md index 5689766..6f2a2ca 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -31,6 +31,12 @@ The first localized core pages use English source revision links are a subsequent documentation update; they are not inside the immutable v0.23.0 PyPI distributions or v0.23.0 tag. +On 2026-10-11, both limitation summaries were reviewed together with the English +path-reference changes built on source revision +`38bc911273b449e4a23287540767c9ca70108c65`: conditional references, repository +roles, path-index/discovery separation, symlink and host-case limits. This is an +unreleased synchronized source update, not a change to the original v0.23.0 tag. + When an English source page changes, review both variants together and record the new source revision here. Preserve executable examples, rule IDs and JSON keys verbatim. Keep the score limitations and failure/exit semantics aligned; diff --git a/scripts/run_path_development_check.py b/scripts/run_path_development_check.py new file mode 100644 index 0000000..149e21b --- /dev/null +++ b/scripts/run_path_development_check.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Replay a local verified snapshot with current rules as DEVELOPMENT evidence. + +Not the frozen tool probe, an independent holdout, or an accuracy comparison. +No download, foreign command, model run, human label or maintainer contact. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import platform +import tarfile + +from agent_config_score import __version__ +from run_pinned_tool_probe import BASE, inputs, local_archive, probe, unpack +from run_review_comparison import _digest, _tool_fingerprint, _write_outputs, load_corpus + + +def development_policy(frozen: dict) -> dict: + # Only the product fingerprint is substituted for current-code verification. + # Never rewrite the frozen protocol, its source identity, safety or limits. + return {**frozen, "tool_source_sha256": _tool_fingerprint()} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True, help="Existing pinned source archive; no download") + parser.add_argument("--output-dir", type=Path, required=True, help="New output directory on a compatible filesystem") + args = parser.parse_args() + try: + if args.output_dir.exists() or args.output_dir.is_symlink(): + raise ValueError("development check requires a new output directory") + frozen = json.loads((BASE / "tool-probe" / "replay-protocol.json").read_text(encoding="utf-8")) + reuse = json.loads((BASE / "development-reuse.json").read_text(encoding="utf-8")) + if reuse["status"] != "development_reuse_not_independent_holdout": + raise ValueError("explicit development reuse disclosure is required") + policy = development_policy(frozen) + corpus = load_corpus(BASE / "context" / "corpus.json") + packet = json.loads((BASE / "pilot" / "review-packet.json").read_text(encoding="utf-8")) + case, tree = inputs(corpus, packet, policy, BASE / "context") + archive = local_archive(args.archive, policy) + args.output_dir.mkdir(parents=True) + root = args.output_dir / "vendor" / "source" + print("Verifying local source data before current-rule development checks.", flush=True) + snapshot = unpack(args.archive, root, tree, policy) + snapshot["canonical_tree_object_sha"] = policy["canonical_tree_object_sha"] + result = probe(root, case, policy) + report = { + "schema_version": 1, "status": "development_check_not_independent_evaluation", + "completed_at_utc": datetime.now(timezone.utc).isoformat(), + "selected_case_id": case["id"], "repository": case["provenance"]["repository"], + "source_revision": case["provenance"]["revision"], + "development_change_base_revision": reuse["base_product_revision"], + "product_state": "current source checkout, identified by tool fingerprint; package version alone does not identify rule state", + "package_version": __version__, "python_version": platform.python_version(), + "host_platform": platform.system(), "tool_source_sha256": _tool_fingerprint(), + "development_script_sha256": hashlib.sha256(Path(__file__).read_text(encoding="utf-8").encode()).hexdigest(), + "frozen_protocol_sha256": _digest(frozen), "development_policy_sha256": _digest(policy), + "policy_derivation": "Same source, scopes and safety limits as frozen protocol; only tool_source_sha256 replaced for current rules in memory. No frozen artifact was rewritten.", + "development_reuse_sha256": _digest(reuse), "context_corpus_sha256": _digest(corpus), + "packet_sha256": packet["packet_sha256"], "archive": archive, "snapshot": snapshot, + "used_for_rule_development": True, **result, + } + _write_outputs([(args.output_dir / "report.json", json.dumps(report, indent=2, ensure_ascii=False) + "\n")]) + print("Retained development-only observations. Independent accuracy and human labels remain unavailable.") + except (ValueError, OSError, EOFError, tarfile.TarError) as exc: + parser.exit(2, f"development check failed ({type(exc).__name__}); no completed report.\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agent_config_score/scanner.py b/src/agent_config_score/scanner.py index 0f24033..62e5a9c 100644 --- a/src/agent_config_score/scanner.py +++ b/src/agent_config_score/scanner.py @@ -9,6 +9,7 @@ import html import ipaddress import json +import os import re import shlex from urllib.parse import unquote, urlsplit @@ -27,6 +28,9 @@ ".git", "node_modules", ".venv", "venv", "dist", "build", ".next", "target", "vendor", ".idea", ".vscode", "__pycache__", } +# Instruction discovery skips generated trees. Path existence is a different +# question: real source code can also live in directories named build/dist. +PATH_INDEX_SKIP_DIRS = SKIP_DIRS - {"build", "dist", "target", ".next"} PATH_CANDIDATE = re.compile( r"`([^`\n]{1,160})`|\b((?:src|app|apps|packages|lib|libs|test|tests|docs|scripts|config|configs)/[A-Za-z0-9_./@+\-]+)" @@ -712,6 +716,38 @@ def _local_markdown_links(text: str, context: _InstructionContext): yield candidate, _line(text, match.start()) +def _non_required_path_reference(text: str, match: re.Match, value: str) -> bool: + """Recognize narrow, same-line contexts; never exempt an entire line.""" + line_start = text.rfind("\n", 0, match.start()) + 1 + line_end = text.find("\n", match.end()) + if line_end == -1: + line_end = len(text) + before = text[max(line_start, match.start() - 256):match.start()] + after = text[match.end():line_end] + # An immediate, closed existence condition does not assert the file exists. + # Do not infer optionality from distant words, an unclosed phrase or a link. + if re.match(r"^[ \t]*\([ \t]*if[ \t]+(?:it[ \t]+)?exists[ \t]*" + r"(?:\)|[-:][^()\r\n]{1,120}\))", after, re.I): + return True + # A two-component GitHub slug may look like a source path (owner/tool.js). + # Require a repository/remote role, not merely 'GitHub' somewhere nearby. + slug = r"[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+" + if not re.fullmatch(slug, value): + return False + if re.match(r"^[ \t]+(?:repository|remote)(?:[ \t]+itself)?" + r"[ \t]*(?:[.,;:!?)]|$)", after, re.I): + return True + if re.search(r"\b(?:merge target is|fork of|re-pushed to a branch in|" + r"CI trusts branches inside)[ \t]*$", before, re.I): + return True + # 'a fork of `owner/repo.js` and not `owner/repo.js` itself' refers to + # the same slug twice. An unrelated local file in that sentence still counts. + repeated = re.search(r"\bfork of[ \t]+`?(" + slug + r")`?[ \t]+and not[ \t]*$", + before, re.I) + return bool(repeated and repeated.group(1) == value + and re.match(r"^[ \t]+itself[ \t]*(?:[.,;:!?)]|$)", after, re.I)) + + def _candidate_paths(text: str, context: _InstructionContext): link_spans = [match.span() for match in MARKDOWN_LINK.finditer(text)] for m in PATH_CANDIDATE.finditer(text): @@ -732,6 +768,8 @@ def _candidate_paths(text: str, context: _InstructionContext): continue if not _looks_like_repository_path(value): continue + if _non_required_path_reference(text, m, value): + continue yield value.replace("\\", "/"), _line(text, m.start()) @@ -784,12 +822,34 @@ def _candidate_exists( def _repository_path_index(root: Path) -> set[str]: + root = root.resolve() paths: set[str] = set() - for path in root.rglob("*"): - rel_path = path.relative_to(root) - if any(part in SKIP_DIRS for part in rel_path.parts): - continue - paths.add(rel_path.as_posix()) + # Prune dependency/admin trees before descent, and never follow directory + # symlinks. This indexes names only; it does not read generated source files. + for directory, dirs, files in os.walk(root, followlinks=False): + descend = [] + for name in dirs: + if name in PATH_INDEX_SKIP_DIRS: + continue + path = Path(directory) / name + rel = path.relative_to(root).as_posix() + target = _repo_candidate(root, root, rel) + if target is None or not target.exists(): + continue + paths.add(rel) + # Resolved aliases include Windows junctions on versions where + # is_symlink() alone would not stop traversal or directory loops. + if target == path and not path.is_symlink(): + descend.append(name) + dirs[:] = descend + for name in files: + path = Path(directory) / name + rel = path.relative_to(root).as_posix() + if path.is_symlink(): + target = _repo_candidate(root, root, rel) + if target is None or not target.exists(): + continue + paths.add(rel) return paths diff --git a/tests/test_human_review_packet.py b/tests/test_human_review_packet.py index e762e62..6687837 100644 --- a/tests/test_human_review_packet.py +++ b/tests/test_human_review_packet.py @@ -55,6 +55,15 @@ def test_consent_and_preparation_do_not_become_completed_evaluation(self): self.assertIn("同意审阅不算已完成标注", page) self.assertIn("不要运行其中的命令", page) + def test_current_status_is_deferred_without_invented_annotations(self): + status = json.loads((self.base / "human-pilot" / "status.json").read_text(encoding="utf-8")) + self.assertEqual(status["annotation_status"], "deferred_no_human_annotation") + self.assertEqual(status["annotations"], []) + self.assertEqual(status["human_reference_status"], "unreviewed") + self.assertFalse(status["comparison_ready"]) + self.assertIsNone(status["correctness_metrics"]) + self.assertEqual(self.protocol["annotation_status"], "awaiting_annotation") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_path_development_check.py b/tests/test_path_development_check.py new file mode 100644 index 0000000..c1fa5bf --- /dev/null +++ b/tests/test_path_development_check.py @@ -0,0 +1,87 @@ +import json +from pathlib import Path +import sys +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from run_path_development_check import development_policy +from run_review_comparison import _digest, _tool_fingerprint, load_corpus + + +class DevelopmentReuseTests(unittest.TestCase): + def setUp(self): + self.base = ROOT / "benchmarks" / "prospective-review-v1" + self.frozen = json.loads((self.base / "tool-probe" / "replay-protocol.json").read_text(encoding="utf-8")) + self.reuse = json.loads((self.base / "development-reuse.json").read_text(encoding="utf-8")) + + def test_current_code_policy_never_modifies_frozen_protocol(self): + original = _digest(self.frozen) + policy = development_policy(self.frozen) + self.assertEqual(_digest(self.frozen), original) + self.assertEqual(original, self.reuse["frozen_protocol_sha256"]) + self.assertEqual(policy["tool_source_sha256"], _tool_fingerprint()) + for key, value in self.frozen.items(): + if key != "tool_source_sha256": + self.assertEqual(policy[key], value) + + def test_reused_case_keeps_original_inputs_and_unresolved_reference(self): + corpus = load_corpus(self.base / "context" / "corpus.json") + report = json.loads((self.base / "tool-probe" / "report.json").read_text(encoding="utf-8")) + self.assertEqual(_digest(corpus), self.reuse["frozen_context_corpus_sha256"]) + self.assertEqual(_digest(report), self.reuse["frozen_tool_report_sha256"]) + self.assertEqual(report["source_revision"], self.reuse["source_revision"]) + self.assertEqual(self.reuse["status"], "development_reuse_not_independent_holdout") + self.assertEqual(self.reuse["annotations"], []) + self.assertFalse(self.reuse["comparison_ready"]) + self.assertIsNone(self.reuse["correctness_metrics"]) + self.assertTrue(all(case["label"] == "unresolved" for case in corpus["cases"])) + + def test_development_runner_help_is_offline_and_explicit(self): + result = subprocess.run([sys.executable, str(ROOT / "scripts" / "run_path_development_check.py"), "--help"], + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("DEVELOPMENT", result.stdout) + self.assertIn("--archive", result.stdout) + self.assertIn("--output-dir", result.stdout) + + def test_existing_output_directory_is_not_overwritten(self): + with tempfile.TemporaryDirectory() as directory: + sentinel = Path(directory) / "sentinel.txt" + sentinel.write_text("preserve\n", encoding="utf-8") + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "run_path_development_check.py"), + "--archive", str(Path(directory) / "absent.tar.gz"), "--output-dir", directory], + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 2) + self.assertNotIn("Traceback", result.stderr) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve\n") + self.assertFalse((Path(directory) / "report.json").exists()) + + def test_retained_development_check_does_not_become_accuracy_evidence(self): + report = json.loads((self.base / "tool-probe" / "development" / "report.json").read_text(encoding="utf-8")) + frozen_report = json.loads((self.base / "tool-probe" / "report.json").read_text(encoding="utf-8")) + self.assertEqual(report["status"], "development_check_not_independent_evaluation") + self.assertTrue(report["used_for_rule_development"]) + self.assertEqual(report["development_reuse_sha256"], _digest(self.reuse)) + self.assertEqual(report["frozen_protocol_sha256"], _digest(self.frozen)) + for key in ("source_revision", "context_corpus_sha256", "packet_sha256", "snapshot", "input_verification"): + self.assertEqual(report[key], frozen_report[key]) + self.assertEqual(report["archive"]["archive_sha256"], frozen_report["archive"]["archive_sha256"]) + self.assertFalse(report["comparison_ready"]) + self.assertIsNone(report["correctness_metrics"]) + self.assertEqual(report["human_reference_status"], "unreviewed") + self.assertEqual(report["hybrid_status"], "not_run") + self.assertNotEqual(report["tool_source_sha256"], frozen_report["tool_source_sha256"]) + for scope in ("native_discovery", "root_instruction_scope"): + self.assertEqual(report[scope]["files"], frozen_report[scope]["files"]) + self.assertEqual(report[scope]["suppressed_findings"], []) + self.assertEqual([f["code"] for f in report[scope]["findings"]], ["context-large"]) + self.assertTrue(report[scope]["review_needed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_path_references.py b/tests/test_path_references.py new file mode 100644 index 0000000..9754251 --- /dev/null +++ b/tests/test_path_references.py @@ -0,0 +1,168 @@ +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from agent_config_score.scanner import _repository_path_index, analyze, discover + + +FORMATS = ("AGENTS.md", "CLAUDE.md", "GEMINI.md", ".cursorrules", + ".github/copilot-instructions.md", ".cursor/rules/review.mdc") + + +class PathReferenceTests(unittest.TestCase): + def findings(self, text, filename="AGENTS.md", existing=()): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in existing: + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("source\n", encoding="utf-8") + source = root / filename + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text(text, encoding="utf-8") + return analyze(root).findings + + def dead_lines(self, text, **kwargs): + return [f.line for f in self.findings(text, **kwargs) if f.code == "dead-path"] + + def test_repository_roles_are_not_local_paths_across_formats(self): + text = ( + "The branch is part of the `acme/tool.js` repository.\n" + "Resolve the `acme/tool.py` remote itself, then fetch it.\n" + "Fork PRs have a merge target is `acme/tool.ts`.\n" + "Commits are re-pushed to a branch in `acme/tool.rs`.\n" + "CI trusts branches inside `acme/tool.json`.\n" + "The GitHub repository is a fork of `acme/tool.js` and not `acme/tool.js` itself.\n" + ) + for filename in FORMATS: + with self.subTest(filename=filename): + self.assertEqual(self.dead_lines(text, filename=filename), []) + + def test_repository_words_do_not_hide_local_files(self): + text = ( + "The GitHub repository uses `src/missing.js`.\n" + "Read `src/config.json` repository file before proceeding.\n" + "Use a branch in `src/missing.ts`.\n" + "The `acme/tool.js` repository. Read `src/missing.py`.\n" + "Resolve the `acme/tool.js` remote itself.\n" + "Read `src/missing.rs` first.\n" + "A fork of `acme/tool.js` and not `src/local.js` itself.\n" + "A fork of `acme/tool.js`; read `src/local.js`.\n" + "The `acme/tool.js`\nrepository.\n" + ) + self.assertEqual(self.dead_lines(text), [1, 2, 3, 4, 6, 7, 8, 9]) + + def test_closed_immediate_existence_conditions_across_formats(self): + text = ( + "Read `docs/guide.md` (if exists).\n" + "Read `docs/guide.md` (if it exists).\n" + "Read docs/guide.md (IF EXISTS - closest to target file).\n" + "Read `docs/guide.md` (if exists: closest to target file).\n" + "Read `docs/guide.md` (if exists), then `src/required.py`.\n" + "Read `src/another.py`, then `docs/guide.md` (if exists).\n" + ) + for filename in FORMATS: + with self.subTest(filename=filename): + self.assertEqual(self.dead_lines(text, filename=filename), [5, 6]) + + def test_optional_words_do_not_hide_required_paths(self): + text = ( + "Optional workflow: read `src/missing.py`.\n" + "Read `src/missing.py` (if needed).\n" + "Read `src/missing.py` (if exists\n" + "Read `src/missing.py`\n(if exists).\n" + "Read `src/missing.py` first (if exists).\n" + "Read `src/missing.py` (if exists and is writable).\n" + "Read `src/missing.py` (if exists: (nested)).\n" + ) + self.assertEqual(self.dead_lines(text), [1, 2, 3, 4, 6, 7, 8]) + + def test_actual_markdown_links_still_require_targets(self): + text = ( + "[Guide](docs/missing.md) (if exists).\n" + "[Remote](acme/tool.js) repository.\n" + ) + self.assertEqual(self.dead_lines(text), [1, 2]) + + def test_path_context_never_exempts_dangerous_commands(self): + text = "Read `docs/guide.md` (if exists), then run `rm -rf cache`.\n" + findings = self.findings(text) + self.assertNotIn("dead-path", [f.code for f in findings]) + self.assertIn("rm-rf", [f.code for f in findings]) + + def test_package_relative_source_in_build_named_trees_is_found(self): + for name in ("build", "dist", "target", ".next"): + with self.subTest(name=name): + self.assertEqual(self.dead_lines( + f"Build entry: `src/{name}/index.ts`.\n", + existing=(f"packages/client/src/{name}/index.ts",)), []) + self.assertEqual(self.dead_lines(f"Build entry: `src/{name}/missing.ts`.\n"), [1]) + + def test_path_index_changes_do_not_expand_instruction_discovery(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "AGENTS.md").write_text("Root instructions.\n", encoding="utf-8") + for name in ("build", "dist", "target", ".next", "node_modules", ".git", "vendor"): + path = root / "packages" / "client" / name + path.mkdir(parents=True) + (path / "AGENTS.md").write_text("Do not discover this.\n", encoding="utf-8") + (path / "index.ts").write_text("source\n", encoding="utf-8") + self.assertEqual(discover(root), [root / "AGENTS.md"]) + index = _repository_path_index(root) + for name in ("build", "dist", "target", ".next"): + self.assertIn(f"packages/client/{name}/index.ts", index) + for name in ("node_modules", ".git", "vendor"): + self.assertNotIn(f"packages/client/{name}/index.ts", index) + + def test_dependency_tree_cannot_satisfy_package_relative_reference(self): + self.assertEqual(self.dead_lines("Read `src/dependency.py`.\n", + existing=("node_modules/client/src/dependency.py",)), [1]) + + def test_dependency_tree_is_pruned_before_descent(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + excluded = root / "node_modules" / "nested" + excluded.mkdir(parents=True) + (excluded / "entry.py").write_text("source\n", encoding="utf-8") + visited = [] + original = os.scandir + + def traced(path): + visited.append(Path(path)) + return original(path) + + with patch("agent_config_score.scanner.os.scandir", side_effect=traced): + _repository_path_index(root) + self.assertIn(root, visited) + self.assertNotIn(root / "node_modules", visited) + self.assertNotIn(excluded, visited) + + def test_index_does_not_follow_directory_links_or_external_targets(self): + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory) + root = workspace / "repo" + root.mkdir() + outside = workspace / "outside" + outside.mkdir() + (outside / "entry.py").write_text("outside\n", encoding="utf-8") + inside = root / "real" + inside.mkdir() + (inside / "entry.py").write_text("inside\n", encoding="utf-8") + try: + os.symlink(outside, root / "external", target_is_directory=True) + os.symlink(inside, root / "alias", target_is_directory=True) + os.symlink(outside / "entry.py", root / "external.py") + os.symlink(root / "absent.py", root / "broken.py") + except OSError: + self.skipTest("symlink creation is unavailable") + index = _repository_path_index(root) + self.assertIn("real/entry.py", index) + self.assertIn("alias", index) + for name in ("external", "external/entry.py", "alias/entry.py", "external.py", "broken.py"): + self.assertNotIn(name, index) + + +if __name__ == "__main__": + unittest.main()