From 4da7579065c3d58fb82f9f0dd2e8125881e41909 Mon Sep 17 00:00:00 2001 From: Haozhe Wu Date: Thu, 15 Oct 2026 21:14:09 +0800 Subject: [PATCH] ci: keep internal documents out of tracked files --- .github/workflows/test.yml | 4 +- CONTRIBUTING.md | 21 ++ .../2026-10-08_analysis_reliability.md | 85 -------- .../2026-10-09_options_strategy_candidates.md | 183 ------------------ tools/ci_grep_gates.sh | 15 +- tools/ci_private_files_gate.py | 72 +++++++ tools/test_ci_private_files_gate.py | 92 +++++++++ 7 files changed, 199 insertions(+), 273 deletions(-) delete mode 100644 docs/sessions/2026-10-08_analysis_reliability.md delete mode 100644 docs/sessions/2026-10-09_options_strategy_candidates.md create mode 100644 tools/ci_private_files_gate.py create mode 100644 tools/test_ci_private_files_gate.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84a880e4..48e8a387 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -116,8 +116,8 @@ jobs: - name: Repository safety gates run: bash tools/ci_grep_gates.sh - - name: Test environment variable gate - run: pytest tools/test_ci_env_var_gate.py -q + - name: Test repository safety gates + run: pytest tools/test_ci_env_var_gate.py tools/test_ci_private_files_gate.py -q - name: Syntax check run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00d82ed4..ec78e933 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,27 @@ For AI-assisted or automation-assisted contributions, also see local checks, higher-risk broker/MCP/credential surfaces, and the expected PR risk notes for agent-authored changes. +## Keep internal documents local + +The repository-root `docs/` directory holds local working notes and must not +be committed. Local agent instructions and memory files (`AGENTS.md`, +`AGENTS.override.md`, `CLAUDE.md`, `CLAUDE.local.md`, `MEMORY.md`, +`CODEX_MEMORY.md`, and `NOTES.md`) must also remain untracked at any depth. +Publish project documentation in `wiki/docs/` or the relevant source directory. + +Before committing, run: + +```bash +python tools/ci_private_files_gate.py +``` + +This check also runs through `bash tools/ci_grep_gates.sh` in CI on pushes and +pull requests. It reads the Git index, so force-adding an ignored document is +still rejected; ignored local files and public documentation are allowed. +It prints filenames only, never document contents. If a file was accidentally +staged, use `git rm --cached -- ` to stop tracking it while preserving the +local copy. This does not erase copies in previous commits. + ## Developer Certificate of Origin (DCO) Every commit in a community pull request MUST carry a `Signed-off-by:` diff --git a/docs/sessions/2026-10-08_analysis_reliability.md b/docs/sessions/2026-10-08_analysis_reliability.md deleted file mode 100644 index 9ae46657..00000000 --- a/docs/sessions/2026-10-08_analysis_reliability.md +++ /dev/null @@ -1,85 +0,0 @@ -# Specialized analysis reliability and interaction - -User scope: implement all proposed options/factor/correlation improvements and investigate repeated missing-data feedback. Baseline: local commit `3bb01ebe`; no remote publishing requested. - -## Confirmed before changes - -- Options: invalid edits leave old metrics visible; request generation changes only at dispatch, leaving a debounce race. Charts dispose/init on every data change. At 1280 px the leg editor horizontally clips columns. -- Correlation: matrix and regime fetch separately; Promise.all discards the successful result if either fails. Input edits clear results; no restored page state or observed sample coverage. -- Factors: full-zoo default, unworkable single-asset BTC universe, progress begins after data loading, job id is lost on navigation even while the backend continues. Job stores are process-local. -- Shared market data: empty DataFrames were counted as successful partial results and removed from the fallback queue. US class-share symbols did not match source routing; concatenated crypto could route to China loaders. - -## Implemented - -- Shared market data treats empty/None frames, including empty resampled frames, - as missing and continues fallback only for unresolved symbols. US class-share - routing and USDT alias normalization preserve requested identities; substituted - providers are attempted once and diagnostics name the actual provider. - Sanitized attempts and recovery guidance distinguish unavailable sources, - failed requests and no bars. Explicit-only currency/fallback boundaries remain. -- S&P 500 factor evaluation uses that shared fallback with observed DataFrames. - Selected fundamental factors load only their declared SEC fields at filing - dates, preserve missing values and reject unsupported universes before price - downloads. Both agent/MCP and shared CLI/API paths use this preparation. -- Factor UI evaluates one selected factor or compares a few, checks configured - readiness, reports loading/computing phases and observed coverage, and warns - when no factor was evaluable. BTC's single-asset pool is unavailable for these - cross-sectional evaluations. Request-ID replay avoids duplicate accepted jobs; - snapshots/SSE and tab-local state recover progress after navigation or reload. -- Correlation matrix and regime use one price snapshot, respect source priority, - normalize duplicate identities, show actual overlapping observations/dates, - and keep successful output when the other analysis lacks sufficient data. -- Options and correlation cancel superseded requests and keep previous results - explicitly labeled. Options charts use the captured result parameters, chart - instances are reused, advanced inputs are collapsed, and the contract chain - loads on expansion with 15 contracts per side around the actual quote. -- Drafts/results persist in the browser tab. All new UI strings cover nine locales. - -## Verified - -- Frontend: 76 test files / 725 tests pass, production build passes, including - locale parity. Log: `/tmp/vibe-trading-uiux-20261008/analysis-frontend-full.log`. -- Related backend suite: 335 tests pass. After the final empty-resampling fix, - the 91-test market-data/reliability subset passes (overlapping, not additive). - Logs: `analysis-backend-final.log` and `final-fallback-tests.log` in the same - temporary directory. Changed Python files pass Ruff; `git diff --check` passes. -- Actual MarketDataTool requests returned bars for AAPL.US, BRK.B.US, - 600519.SH, 0700.HK and BTCUSDT through Yahoo, Tencent and OKX. The combined - correlation path returned AAPL/SPY/BRK.B with 30 common observations for - 2026-08-26 through 2026-10-07 and a regime result. These are live provider - probes, separate from mocked regressions. A final request to the restarted - latest API returned HTTP 200, 30 common observations from 2026-08-27 through - 2026-10-08, a regime result and no errors. -- Actual single-factor alpha101_001 evaluation for sp500 / 2025-2025 completed: - 503 requested instruments, 500 observed, one evaluated factor. Missing: - FDXF.US, HONA.US, VYLR.US. The UI listed these and current-roster survivorship - bias. Navigation/reload recovered the result. After restarting the isolated - backend with the latest source, the UI still read the saved result; a new - job `4a84ae13fdf948ffaebb8650e309bec3` reused the panel and completed with the - same coverage and factor result. Initial progress was 0/1, terminal progress 1/1. -- Actual PIT SEC panel for AAPL.US/MSFT.US returned net_income and - shares_diluted. This verifies real field retrieval, not a full 503-company - fundamental benchmark. Health requests stayed responsive during the large - price fetch (0.5–13.1 ms in five observations). -- Local browser QA: late payoff response/edit races, result restoration, - correlation partial results, chain 15/15 versus full 60/56 rows, desktop leg - editor at 1280 px, and no whole-page overflow at 390 px. Stored Chinese/dark - preferences were preserved. Screenshot: `factor-final.png` in the temporary - directory. Preview: http://127.0.0.1:5899/. - -## Remaining operational limits - -- First large-universe download still takes minutes because real public-source - calls are rate-limited. Scope selection and reuse avoid unnecessary work; - source throttling and scientific sample gates were not reduced. -- Historical absence, credentials, optional dependencies and provider outages - can still prevent data retrieval. Diagnostics expose these instead of - fabricating values. Configured readiness does not promise live availability. -- Job stores remain process-local; restart loses unfinished jobs. Completed - snapshots are pruned after one hour, while completed tab-local results remain - readable. New submissions after lost process state can start fresh jobs. -- Current S&P 500 constituents carry survivorship bias. The pre-existing static - constituent-source date was not independently refreshed in this task. -- No protected agent/session/provider code, live broker action or remote - publishing was performed. Runtime caches and live probe outputs stayed outside - the checkout under `/tmp/vibe-trading-uiux-20261008/`. diff --git a/docs/sessions/2026-10-09_options_strategy_candidates.md b/docs/sessions/2026-10-09_options_strategy_candidates.md deleted file mode 100644 index 58be0fc9..00000000 --- a/docs/sessions/2026-10-09_options_strategy_candidates.md +++ /dev/null @@ -1,183 +0,0 @@ -# Options strategies worth automating: candidate backtests - -Goal: backtest a small set of options strategies with the existing engine -(`agent/backtest/engines/options_portfolio.py`, unchanged) and decide which, if -any, to carry forward to paper trading. - -Code: `agent/src/skills/options-strategy/example_signal_engine.py` (engines), -`agent/scripts/options_candidates_backtest.py` (this study; reruns end to end -with `PYTHONPATH=agent python agent/scripts/options_candidates_backtest.py`), -`agent/tests/test_options_strategy_engines.py`. - -## Bottom line - -- **No premium-selling strategy qualifies.** Cash-secured puts, iron condors - and short strangles made between -4% and +4% a year out of sample, against - 15–19% for buy-and-hold. The model has no volatility risk premium: options - are priced at trailing realized vol, and that premium is where these - strategies' edge comes from in practice. **This backtest cannot validate them - either way.** They need real option-chain history first. -- **Carry forward one: a 0.20-delta, ~35-DTE covered call on SPY (QQQ as a - second underlying).** It was the only strategy that matched or beat - buy-and-hold on risk-adjusted terms in both periods for the index ETFs: - - SPY out of sample: Sharpe 1.10 vs 0.94, max drawdown -19% vs -25%, CAGR - about the same. - - QQQ: Sharpe about equal, drawdown -29% vs -35%, about 2 points a year less - CAGR. - - Its result doesn't depend on the missing premium: real calls are richer than - modeled here, which would help it. It lagged buy-and-hold on AAPL and MSFT - (CAGR 4–5 points a year lower), so it is for index ETFs only. -- **Don't carry the protective put forward, even though it looks good.** Puts - are priced at trailing realized vol plus a mild skew, which makes them - several vol points cheaper than real index puts. Its strong in-sample - result (Sharpe 1.2 against 0.86 for SPY) mostly comes from the cheap - pre-COVID puts in February 2020, priced at about 12% vol. In reality those - puts cost far more. -- The vol gate helped the strangle in every run: an "elevated" regime (30-day - realized vol above its 1-year median) beat no gate in sample. Out of sample - it kept drawdowns to 8–19% but still earned ~0–1% a year. The gate's benefit - here comes from realized vol reverting to its mean. Real implied vol already - prices that in, so don't expect the benefit to carry over. - -## Setup - -| Item | Value | -|---|---| -| Underlyings | SPY, QQQ (index ETFs); AAPL, MSFT (single names). yfinance closes, dividend- and split-adjusted | -| In-sample | 2016-01-04 → 2020-12-31 (includes 2020 crash), history from 2015 used for vol warm-up | -| Out-of-sample | 2021-01-04 → 2026-10-08 (includes 2022 bear market), parameters frozen | -| Tuning | one parameter per strategy, best mean Sharpe over SPY+QQQ in sample (grid in the script) | -| Pricing | Black-Scholes on 30-day historical vol, `iv_skew=-0.3` smile, r = 2%, European, next-bar-close fills | -| Costs | commission = 1% of premium per side for ETFs, 2% for single names (≈ half-spread + fees) | -| Margin | engine's CBOE-style naked margin for cash-secured puts and strangles; off for covered call, protective put and condor (see caveats) | -| Capital | $100k, size compounds on each strategy's own realized P&L | - -Strategies (chosen parameters in bold): - -| Strategy | Rules | -|---|---| -| Covered call | shares + short call **0.20Δ** (vs 0.30), ~35 DTE, hold to expiry, shares re-sized to equity each cycle | -| Cash-secured put | short put **0.30Δ** (vs 0.20), ~35 DTE, hold to expiry, notional = capital (fully secured) | -| Iron condor | short **0.16Δ** (vs 0.10/0.25) put+call, 0.05Δ wings, 45 DTE, close at 50% of credit or 21 DTE; max loss 10% of capital per trade | -| Vol-gated strangle | short 0.16Δ put+call, 45 DTE, 50% / 21 DTE; gate **elevated** (vs none / contracting); notional = capital | -| Protective put | shares + long put **0.15Δ** (vs 0.30), ~60 DTE, rolled at 21 DTE | - -## Out-of-sample results (2021-01 → 2026-10) - -CAGR+bill is CAGR after crediting the 13-week T-bill rate on cash (the engine -pays 0% on cash). It matters for cash-heavy short-premium books. - -| Strategy | Code | CAGR | CAGR+bill | Sharpe | MaxDD | Worst month | Worst day | CVaR 5% (daily) | Skew | Win rate | Trades | -|---|---|---|---|---|---|---|---|---|---|---|---| -| buy & hold | SPY | 15.3% | | 0.94 | -24.5% | -9.2% | -5.9% | -2.4% | 0.28 | | | -| covered call | SPY | 15.5% | 15.5% | **1.10** | -19.4% | -8.3% | -5.6% | -2.1% | -0.00 | 81% | 59 | -| cash-secured put | SPY | 2.9% | 5.8% | 0.42 | -13.9% | -7.3% | -4.8% | -1.3% | -0.16 | 85% | 59 | -| iron condor | SPY | -1.4% | 1.6% | -0.18 | -14.8% | -4.2% | -2.8% | -1.2% | -0.51 | 64% | 90 | -| vol-gated strangle | SPY | 0.0% | 3.0% | 0.02 | -8.4% | -3.2% | -3.1% | -0.7% | -1.61 | 72% | 53 | -| protective put | SPY | 15.1% | 15.1% | 1.07 | -20.5% | -8.2% | -3.8% | -1.9% | 0.75 | 14% | 49 | -| buy & hold | QQQ | 17.2% | | 0.82 | -35.1% | -13.6% | -6.2% | -3.2% | 0.15 | | | -| covered call | QQQ | 15.4% | 15.4% | 0.86 | -29.2% | -12.5% | -5.9% | -2.8% | -0.06 | 73% | 59 | -| cash-secured put | QQQ | 3.7% | 6.4% | 0.42 | -21.4% | -7.2% | -4.9% | -1.7% | -0.58 | 88% | 59 | -| iron condor | QQQ | -3.1% | 0.1% | -0.46 | -22.9% | -5.1% | -2.7% | -1.1% | -1.42 | 62% | 90 | -| vol-gated strangle | QQQ | 1.3% | 4.3% | 0.29 | -10.5% | -6.1% | -2.8% | -0.9% | -2.37 | 77% | 52 | -| protective put | QQQ | 16.7% | 16.7% | 0.92 | -31.0% | -12.6% | -4.1% | -2.5% | 0.48 | 18% | 49 | -| buy & hold | AAPL | 18.9% | | 0.77 | -33.4% | -12.2% | -9.2% | -3.9% | 0.34 | | | -| covered call | AAPL | 13.7% | 13.8% | 0.67 | -32.0% | -11.4% | -8.7% | -3.5% | 0.27 | 71% | 59 | -| cash-secured put | AAPL | 3.4% | 6.3% | 0.32 | -23.9% | -6.0% | -6.1% | -2.3% | 0.62 | 78% | 59 | -| iron condor | AAPL | -4.3% | -0.8% | -0.59 | -28.7% | -8.7% | -4.3% | -1.3% | -1.45 | 66% | 90 | -| vol-gated strangle | AAPL | -0.4% | 2.8% | -0.01 | -15.1% | -9.1% | -4.2% | -1.4% | -1.25 | 75% | 52 | -| protective put | AAPL | 17.2% | 17.2% | 0.79 | -29.0% | -12.9% | -6.2% | -3.1% | 0.67 | 16% | 49 | -| buy & hold | MSFT | 17.4% | | 0.73 | -37.1% | -17.2% | -10.0% | -3.7% | 0.54 | | | -| covered call | MSFT | 12.7% | 12.9% | 0.67 | -28.7% | -13.5% | -9.2% | -3.2% | -0.20 | 78% | 59 | -| cash-secured put | MSFT | 4.1% | 7.1% | 0.42 | -18.8% | -7.9% | -6.1% | -2.0% | -0.88 | 86% | 59 | -| iron condor | MSFT | -1.5% | 1.7% | -0.19 | -13.6% | -4.1% | -3.9% | -1.2% | -1.71 | 67% | 91 | -| vol-gated strangle | MSFT | 0.7% | 4.0% | 0.13 | -18.5% | -7.0% | -6.6% | -1.2% | -3.53 | 72% | 47 | -| protective put | MSFT | 13.7% | 13.7% | 0.65 | -33.1% | -14.8% | -10.0% | -3.1% | 1.03 | 22% | 49 | - -Win rate is per structure (all legs of one entry), excluding the share leg. -No opens were rejected for margin in any run. - -## In-sample (2016 → 2020), chosen parameters - -| Strategy | SPY CAGR / Sharpe / MaxDD | QQQ CAGR / Sharpe / MaxDD | -|---|---|---| -| buy & hold | 15.5% / 0.86 / -33.7% | 24.5% / 1.11 / -28.6% | -| covered call 0.20Δ | 13.7% / 0.85 / -32.7% | 20.2% / 1.07 / -27.6% | -| cash-secured put 0.30Δ | -1.2% / -0.02 / -33.1% | 1.1% / 0.15 / -26.8% | -| iron condor 0.16Δ | -2.8% / -0.32 / -26.9% | -3.1% / -0.38 / -26.5% | -| strangle, elevated gate | 1.0% / 0.18 / -18.2% | 3.1% / 0.48 / -12.3% | -| protective put 0.15Δ | 15.8% / 1.20 / -14.4% | 23.0% / 1.31 / -18.9% | - -The full in-sample grid (every parameter, worst month/day, CVaR, skew) is -printed by the script. - -## Tail behavior - -- Short premium has the expected left tail. Every short-premium book has - negative daily skew, worst at -5.3 for the ungated SPY strangle in sample. - In 2020 the SPY cash-secured put lost 16–17% in its worst month, and the - ungated SPY strangle lost 14%. Win rates of 80–90% sit alongside negative CAGR, - so a high win rate is no evidence of an edge here. -- The covered call keeps almost all of the underlying's crash exposure: its - in-sample max drawdown is -33% against -34% for SPY. It mainly gives up - upside, which is why it lags on strongly trending single names. -- The protective put is the only strategy with positive skew, and it cut - the worst day on SPY, QQQ and AAPL. That benefit is overstated for the reason given in the bottom - line. - -## Sensitivity to the smile (SPY, out of sample) - -`iv_skew` is the only setting the engine has for how rich OTM options are. -Changing it barely moves the ranking: - -| iv_skew | covered call Sharpe | CSP CAGR+bill | condor CAGR+bill | strangle CAGR+bill | protective put Sharpe | -|---|---|---|---|---|---| -| 0.0 | 1.15 | 5.1% | 0.9% | 3.9% | 1.18 | -| -0.3 (base) | 1.10 | 5.8% | 1.6% | 3.0% | 1.07 | -| -0.6 | 1.04 | 6.4% | 1.5% | 2.7% | 0.95 | - -## Caveats - -1. **Prices come from a model, not from real option chains.** Every price is - Black-Scholes on trailing 30-day realized vol plus a fixed quadratic smile: - - The volatility risk premium is effectively **zero**. Real index implied - vol has averaged a few points above later realized vol. That gap is the - return premium sellers collect and buyers pay. Here, premium selling - looks worse than it is and option buying looks better. - - Real implied vol jumps the day a crash starts. Here it lags by up to 30 - days: options are cheap going into a crash and expensive after it. - - The bid/ask spread is modeled as a flat 1–2% of premium. Real spreads - widen sharply in a crisis, the exact days that matter for short-premium - strategies. -2. **There is no early assignment and no share delivery.** Options are - European and settle in cash. The covered call and protective put hold a - "share replica" (a call struck at $0.01) and re-size it to equity each - cycle, which approximates assignment and re-buying. The cash-secured put - settles in cash, so it is not a full wheel (no calls written on assigned - shares). Dividends reach the results only through adjusted closes. -3. **Some margin was switched off.** The engine margins every short leg as - naked. That is right for cash-secured puts and strangles. It overcharges - covered calls and defined-risk condors, and it rejected single condor legs - in a trial run, which left the rest of the condor naked. So those three - were run with `margin_enabled: false`: - - the condor is sized so its maximum loss is 10% of capital, which matches - the Reg-T spread requirement; - - the share-based strategies hold about zero cash. -4. **Cash earns 0% in the engine.** The CAGR+bill column adds T-bill interest - after the fact, as simple interest on the engine's cash balance. -5. **This is a small study.** It covers four underlyings, about 11 years, and - ~50–90 trades per strategy per period. The parameter grid is deliberately - small (2–3 values), so the in-sample choice gives only a little room for - overfitting. A Sharpe ratio measured over ~5 years has a standard error of about - 0.45, so none of the Sharpe gaps above are statistically significant. - -## Next steps - -- Paper-trade the SPY 0.20Δ / 35-DTE covered call (QQQ optional). Record real - fills against the model's mid price to measure the actual spread cost. -- Before paper-trading any short-premium strategy, replace the synthetic - pricing with historical chain IV, for example by loading an IV series in - place of `historical_volatility`. That is the input that decides whether - the cash-secured put or the vol-gated strangle has an edge. This study - can't answer that. diff --git a/tools/ci_grep_gates.sh b/tools/ci_grep_gates.sh index 1ede5217..1d131856 100644 --- a/tools/ci_grep_gates.sh +++ b/tools/ci_grep_gates.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ci_grep_gates.sh — repo-wide safety floor enforced in CI. # -# Four gates run sequentially; any failure exits non-zero and names the +# Gates run sequentially; any failure exits non-zero and names the # offending files. Run locally before pushing: # # bash tools/ci_grep_gates.sh @@ -20,6 +20,9 @@ # (e) No raw `os.getenv` / `os.environ.get` / `os.environ["KEY"]` reads # outside the centralized config layer (`agent/src/config/`). # AST-based; uses `tools/ci_env_var_gate.py`. +# (f) No process-wide os patches in tests. +# (g) No internal documents in the Git index, even if force-added. +# Uses `tools/ci_private_files_gate.py`; ignored local files are allowed. # # Exclusions: .git, node_modules, __pycache__, .venv, dist, build, this # script itself. The HTML scan in (c) is scoped to wiki/alpha-library/** @@ -58,8 +61,8 @@ fi # -------------------------------------------------------------- gate (b) # docs/ is excluded: those are internal planning docs that discuss the # trademark policy itself (e.g. "the string 'WorldQuant' must not appear in -# user-facing artifacts"). docs/ is not shipped to PyPI / public consumers -# (memory: feedback_no_push_docs). The gate's real target is source code, +# user-facing artifacts"). Gate (g) prevents these local documents from +# being tracked. This gate's target is source code, # READMEs, HTML/JSON manifests, and the wiki. echo "[gate b] no 'WorldQuant' trademark string in shipped artifacts ..." B_HITS=$(grep -rni --include='*.py' --include='*.md' --include='*.html' --include='*.json' \ @@ -162,6 +165,12 @@ else echo "${GREEN}ok${NC}" fi +# -------------------------------------------------------------- gate (g) +echo "[gate g] no tracked internal documents ..." +if ! python tools/ci_private_files_gate.py; then + FAILED=1 +fi + # --------------------------------------------------------------- result if [ "$FAILED" -ne 0 ]; then echo diff --git a/tools/ci_private_files_gate.py b/tools/ci_private_files_gate.py new file mode 100644 index 00000000..de11fcc4 --- /dev/null +++ b/tools/ci_private_files_gate.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Reject internal documents in the Git index, including force-added files. + +Run before committing with ``python tools/ci_private_files_gate.py``. +Only indexed paths are inspected; ignored local files and their contents are +never read. The same index check runs on CI's checked-out commit. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path, PurePosixPath + + +PRIVATE_DOCUMENT_NAMES = frozenset({ + "agents.md", + "agents.override.md", + "claude.md", + "claude.local.md", + "memory.md", + "codex_memory.md", + "notes.md", +}) + + +def main(repo_root: Path | None = None) -> int: + """Check the Git index for internal document paths. + + Args: + repo_root: Repository to check; defaults to this script's repository. + + Returns: + Zero for a clean index, one for forbidden paths, or two if Git fails. + """ + root = repo_root if repo_root is not None else Path(__file__).resolve().parent.parent + try: + result = subprocess.run( + ["git", "ls-files", "--cached", "--full-name", "-z"], + cwd=root, + capture_output=True, + check=True, + ) + except (OSError, subprocess.CalledProcessError): + print("FAIL: cannot read the Git index; internal-document check did not run.") + return 2 + + forbidden = [] + for raw_path in result.stdout.split(b"\0"): + if not raw_path: + continue + name = os.fsdecode(raw_path) + path = PurePosixPath(name) + if path.parts[0].casefold() == "docs" or path.name.casefold() in PRIVATE_DOCUMENT_NAMES: + forbidden.append(name) + + if forbidden: + print("FAIL: internal documents are tracked in Git:") + for name in sorted(set(forbidden)): + # Escape control characters so a filename cannot inject CI output. + print(f" {ascii(name)}") + print("Use git rm --cached -- to retain the local file without tracking it.") + print("Publish project documentation under wiki/docs/ or the relevant source directory.") + return 1 + + print("OK: no internal documents are tracked in Git.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_ci_private_files_gate.py b/tools/test_ci_private_files_gate.py new file mode 100644 index 00000000..2ebe8616 --- /dev/null +++ b/tools/test_ci_private_files_gate.py @@ -0,0 +1,92 @@ +"""Exercise the privacy gate against real temporary Git indexes.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from ci_private_files_gate import main + + +def _git(root: Path, *args: str) -> None: + """Run Git in an isolated test repository.""" + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """Create a real repository with an ignored internal-documents directory.""" + _git(tmp_path, "init", "-q") + (tmp_path / ".gitignore").write_text("/docs/\nAGENTS.md\nCLAUDE.md\n", encoding="utf-8") + _git(tmp_path, "add", "--", ".gitignore") + return tmp_path + + +@pytest.mark.parametrize("name", [ + "docs/sessions/private.md", + "docs/plans/design.json", + "docs", + "DOCS/private.md", + "AGENTS.md", + "agent/AGENTS.override.md", + "CLAUDE.md", + "frontend/CLAUDE.local.md", + "MEMORY.md", + "nested/CODEX_MEMORY.md", + "NOTES.md", + "docs/notes with spaces\nand newline.md", +]) +def test_force_added_internal_documents_fail(repo: Path, name: str, capsys) -> None: + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("private content must never appear in output", encoding="utf-8") + _git(repo, "add", "-f", "--", name) + + assert main(repo) == 1 + output = capsys.readouterr().out + assert ascii(name) in output + assert "private content" not in output + + +def test_public_docs_and_ignored_local_notes_pass(repo: Path) -> None: + for name in ["wiki/docs/guide.md", "agent/src/skills/example/SKILL.md", "README.md"]: + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("public documentation", encoding="utf-8") + _git(repo, "add", "--", name) + (repo / "docs").mkdir() + (repo / "docs/private.md").write_text("local notes", encoding="utf-8") + (repo / "CLAUDE.md").write_text("local instructions", encoding="utf-8") + + assert main(repo) == 0 + + +def test_unstaged_deletion_does_not_hide_an_indexed_file(repo: Path) -> None: + path = repo / "docs/private.md" + path.parent.mkdir() + path.write_text("private", encoding="utf-8") + _git(repo, "add", "-f", "--", "docs/private.md") + path.unlink() + + assert main(repo) == 1 + + +def test_untracking_keeps_local_content_and_clears_failure(repo: Path) -> None: + path = repo / "docs/private.md" + path.parent.mkdir() + original = b"local notes\r\n\x00unchanged\n" + path.write_bytes(original) + _git(repo, "add", "-f", "--", "docs/private.md") + assert main(repo) == 1 + + _git(repo, "rm", "--cached", "--", "docs/private.md") + + assert path.read_bytes() == original + assert main(repo) == 0 + + +def test_missing_git_repository_fails_closed(tmp_path: Path, capsys) -> None: + assert main(tmp_path) == 2 + assert "check did not run" in capsys.readouterr().out