diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 33e8fdc7e6..513eda4ab1 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -91,6 +91,16 @@ git clone https://github.com/RCOSDP/weko-secret.git ~/weko-secret **private 側を public リポジトリの中に置かない。** 誤って commit する事故を防ぐため、 必ず別の場所に clone する(`docs/RULE.md` §1)。 +clone したら、private 側で pre-push フックを有効にしておく(clone ごとに 1 回)。 + +```bash +git -C ~/weko-secret config core.hooksPath .githooks +``` + +**private 側は GitHub Actions を使わない。** private リポジトリは Actions の利用枠の +制限を受けるため、台帳の検査は手元の `ci/local.sh` で回す運用にしている。このフックが +push のたびにそれを回し、落ちたら push を止める。Actions で回るのは public 側だけ。 + #### 解析対象のソースを測定リビジョンに固定する 台帳は「どのリビジョンを測ったか」とセットでしか意味を持たない(`docs/RULE.md` 規則 2-3)。 @@ -111,10 +121,14 @@ git -C ~/weko worktree add --detach ~/weko3 "$REV" 向けないこと。** 探索順で古いツールが先に当たる。何も指定しないのが正しい。 ```bash -cd ~/weko-secret && python3 -m pytest # 実機も Docker も要らない +cd ~/weko-secret && ci/local.sh # 実機も Docker も要らない。約 1 分 ``` -列・語彙・派生列の再現・経路の漏れをまとめて見る。**台帳を触ったら必ず回す。** +列・語彙・派生列の再現・経路の漏れ・`no` の固定をまとめて見る。**台帳を触ったら必ず回す。** +push 時にはフックが同じものを回す。`ci/local.sh` はツールを同名ブランチの先頭から、 +解析対象のソースを台帳の測定リビジョンから、それぞれ `~/.cache/weko-secret-ci/` に +取り出して使うので、この worktree の状態には左右されない。編集中にさっと確かめるだけなら +`python3 -m pytest`(この worktree を解析対象に使う)でもよい。 バージョンを上げて台帳の測定先を移したら、この worktree は貼り替える (`git -C ~/weko worktree remove ~/weko3` してから作り直す)。 @@ -439,6 +453,19 @@ python3 $INV/reconcile.py --gate # 「✅ 一致(0件)」になる 消えた経路(B)は `reconcile_allow.json` に**理由付きで**登録する。理由なしの登録は禁止(`docs/RULE.md` §3-3)。 +**`no` は台帳の主キー。振り直さない・使い回さない。** 起票(`fix_ticket`)や許可リストが +番号で行を指しているので、振り直すと黙って別の経路を指す。払い出した番号は private 側の +`no_registry.tsv` に全部残っている。 + +- 行を足す: `add_row.py --append` を使う。台帳と `no_registry.tsv` の両方に次の番号で書く。 +- 行を消す: 台帳から消してよいが、**番号は詰めない**。`no_registry.tsv` の行は残し、 + `status` を `廃止`、`note` に理由を書く。 +- 経路の表記だけが変わった(uri の改名、method の追加など): 同じ番号のまま書き換え、 + `no_registry.tsv` の `note` に旧値を書く。 + +詳しくは `tools/api-inventory/scripts/README.md` のケース2。守れていなければ、private 側の +検査(`tests/test_no_stability.py`)がリリースタグと `main` の台帳と突き合わせて落とす。 + **外部調査と件数が合わないときは、まず相手の環境を疑う。** v2.1.0 ではベンダ資料が新規 23 件としていたが、ソースに存在したのは 5 件だけだった。 残り 18 件は `modules/` ではなく site-packages 側の pip パッケージの版差が原因で、grep しても 1 件も出なかった。 @@ -634,6 +661,10 @@ tools/release/open-pr.sh --base develop_v2.1.0 --inventory-base main --run --inv gh pr checks --watch ``` +private 側の push では pre-push フックが `ci/local.sh` を回す(約 1 分)。落ちたら push は +止まるので、直してから出し直す。private 側の PR には Actions のチェックが付かないので、 +`gh pr checks` に出るのは public 側だけ。 + **マージしてから**、両リポジトリに**同名のタグ**を打つ(`docs/RULE.md` 規則 2-3)。 ```bash diff --git a/tools/api-inventory/scripts/README.md b/tools/api-inventory/scripts/README.md index bb39e005ad..b24952f40c 100644 --- a/tools/api-inventory/scripts/README.md +++ b/tools/api-inventory/scripts/README.md @@ -113,7 +113,7 @@ python3 tools/api-inventory/scripts/reconcile.py python3 tools/api-inventory/scripts/add_row.py --endpoint api:weko_admin.foo # URI の一部でも探せる: --uri /api/items/import-task -# 3) 追記する +# 3) 追記する(no_registry.tsv にも同じ番号で書き足される) python3 tools/api-inventory/scripts/add_row.py --endpoint api:weko_admin.foo --append # 4) TODO の列を埋める(下記) @@ -130,6 +130,33 @@ python3 tools/api-inventory/scripts/build_checklist.py python3 tools/api-inventory/scripts/reconcile.py --gate # 差分0になること ``` +### `no` は主キー。振り直さない・使い回さない + +`no` は起票(`fix_ticket`)・許可リスト・所見の本文から参照される。振り直すと +それらが**黙って別の経路を指す**。払い出した番号はプライベートリポジトリ側の +`no_registry.tsv` に、廃止した番号も含めて全部残っている。 + +`add_row.py` は、台帳と `no_registry.tsv` の両方の最大値の次を新しい番号にし、 +`--append` のとき `no_registry.tsv` にも同じ番号で書き足す。台帳だけを見て +「最大値 + 1」にすると、末尾の行を廃止した直後にその番号をもう一度振ってしまう。 +手で行を足すときも、この二つを揃えること。 + +### 台帳から行を消す + +経路が無くなった行は台帳から消してよいが、**番号は詰めない**。 + +1. 台帳(`weko3_api_list_full.tsv`)から行を消す +2. `no_registry.tsv` の同じ番号の行は**消さずに**、`status` を `廃止` にし、 + `note` に理由を書く +3. 手順6(派生列の再計算 → 32列版の再生成 → 突き合わせ)を回す + +経路の表記だけが変わった(uri の改名、method の追加など)ときは、行を消して +足し直すのではなく、同じ番号のまま台帳と `no_registry.tsv` を書き換え、 +`no_registry.tsv` の `note` に旧値を書く。 + +振り直しや使い回しは、プライベートリポジトリ側の `tests/test_no_stability.py` が +リリースタグと `main` の台帳と突き合わせて落とす。 + ### 機械付与スクリプトで TODO を減らす `add_row.py --append` の直後に、Phase 2 の機械付与スクリプトを流すと `TODO` が減る。 @@ -695,7 +722,7 @@ git push origin main --follow-tags | `test_coverage.py` | full.tsv + テストコード | full.tsv の 59-63列 | | `prioritize.py` | full.tsv | full.tsv の 57-58, 64列 + 末尾列順の正規化 | | `build_checklist.py` | full.tsv | **`weko3_api_list.tsv` を全体再生成** | -| `add_row.py` | `api_snapshot.json` + git | full.tsv に新規行の雛形を追記(`--append`) | +| `add_row.py` | `api_snapshot.json` + git | full.tsv に新規行の雛形を追記し、`no_registry.tsv` に番号を払い出す(`--append`) | | `apply_probe_results.py` | probe.json | full.tsv の `dynamic_verified`(空欄のみ / `--overwrite` で差し替え、`--keep-history` で旧値を ` ‖ 旧: ` として残す) | | `measure.sh` | `measure_profile.json` | 実測の唯一の入口。上記を固定順で回し `measure_report.md` を書く | | `_ensure_profile.py` / `_read_profile.py` / `_targets.py` / `_report.py` | — | `measure.sh` の内部ヘルパ | @@ -1080,6 +1107,10 @@ python3 tools/api-inventory/probe.py probe_results.json # 未認証+各ロー python3 tools/api-inventory/merge.py out/ merged.tsv # 分割TSVを結合・重複排除・採番 ``` +**初回生成専用。** 全行に `no` を振り直すので、既存の台帳に使ってはいけない +(`no` は主キーで、振り直すと起票や許可リストが別の経路を指す)。既存の台帳に +行を足すのは `add_row.py`。 + ## Phase 5: チェックリスト版(32列)を生成 ```bash python3 tools/api-inventory/scripts/build_checklist.py # 64列 full → 32列 に統合 diff --git a/tools/api-inventory/scripts/add_row.py b/tools/api-inventory/scripts/add_row.py index 85e5096499..bf1ce44c6c 100644 --- a/tools/api-inventory/scripts/add_row.py +++ b/tools/api-inventory/scripts/add_row.py @@ -19,8 +19,14 @@ 派生列(priority / test_* / cleanup)は空のままでよい。後で test_coverage.py → prioritize.py が付与する。 + +`no` は台帳の主キー。**一度振った番号は変えず、使い回さない**。払い出した番号は +`no_registry.tsv`(廃止した番号も残る)にあるので、新しい番号はそこと台帳の +両方の最大値の次にする。台帳だけを見ると、末尾の行を廃止した直後にその番号を +もう一度振ってしまう。`--append` では `no_registry.tsv` にも同じ番号で書き足す。 """ import argparse +import fcntl import json import os import re @@ -32,6 +38,8 @@ from snapshot import default_weko_root # noqa: E402 AUTO_TODO = 'TODO' +REGISTRY = 'no_registry.tsv' +REGISTRY_COLUMNS = ['no', 'app', 'method', 'uri', 'endpoint', 'status', 'note'] DERIVED = ('priority', 'priority_reason', 'test_normal', 'test_abnormal', 'test_boundary', 'test_exception', 'test_gap', 'cleanup') @@ -131,6 +139,20 @@ def build(hdr, snap_key, e, root, next_no): return [v[n] for n in hdr] +def _nos(lines): + return [int(l.split('\t')[0]) for l in lines[1:] if l.split('\t')[0].isdigit()] + + +def next_no(full_lines, registry_lines): + """台帳と払い出し記録の最大値の次の番号(欠番は埋めない)。""" + return max(_nos(full_lines) + _nos(registry_lines) + [0]) + 1 + + +def registry_entry(hdr, row): + v = dict(zip(hdr, row)) + return [v['no'], v['app'], v['method'], v['uri'], v['endpoint'], '現役', ''] + + def main(): p = argparse.ArgumentParser(description='台帳に新規行の雛形を作る') p.add_argument('--endpoint', help='api_snapshot.json のキー(例 api:weko_admin.foo)') @@ -138,6 +160,8 @@ def main(): p.add_argument('--snapshot', default=None) p.add_argument('--full', default=None) p.add_argument('--weko-root', default=None) + p.add_argument('--registry', default=None, + help=f'番号の払い出し記録(既定は台帳と同じ場所の {REGISTRY})') p.add_argument('--append', action='store_true', help='full.tsv に追記する(既定は表示のみ)') a = p.parse_args() snap_p = a.snapshot or data_path('api_snapshot.json') @@ -164,21 +188,44 @@ def full_rules(v): for k in keys: print(' ' + k) + # 番号を読んでから両ファイルに書き終えるまでを排他にする。並行して --append を + # 回すと、同じ最大値を読んで同じ番号を二度払い出してしまうため。ロックは台帳 + # そのものに掛ける(ロック用のファイルを作ると台帳の隣に紛れて commit される)。 + # 'r+' で開く。'a' だと --full の打ち間違いで空の台帳を黙って作ってしまう。 + lock = open(full, 'r+') if a.append else None + if lock: + fcntl.flock(lock, fcntl.LOCK_EX) + lines = open(full, encoding='utf-8').read().rstrip('\n').split('\n') hdr = lines[0].split('\t') - next_no = max(int(l.split('\t')[0]) for l in lines[1:] - if l.split('\t')[0].isdigit()) + 1 + registry = a.registry or os.path.join(os.path.dirname(os.path.abspath(full)), REGISTRY) + reg_lines = (open(registry, encoding='utf-8').read().rstrip('\n').split('\n') + if os.path.isfile(registry) else []) + if reg_lines and reg_lines[0].split('\t') != REGISTRY_COLUMNS: + sys.exit(f'{registry} のヘッダが想定と違います: {reg_lines[0]}') + no = next_no(lines, reg_lines) rows = [] for k in keys: - rows.append(build(hdr, k, E[k], root, next_no)) - next_no += 1 + rows.append(build(hdr, k, E[k], root, no)) + no += 1 if a.append: + # 払い出し記録を先に書く。台帳の書き込みが後で失敗しても、番号が1つ + # 欠番になるだけで済む(逆順だと、台帳にだけある記録漏れの番号が残る)。 + if reg_lines: + with open(registry, 'a', encoding='utf-8') as f: + for r in rows: + f.write('\t'.join(registry_entry(hdr, r)) + '\n') with open(full, 'a', encoding='utf-8') as f: for r in rows: f.write('\t'.join(r) + '\n') + lock.close() print(f'{full} に {len(rows)} 行を追記しました。') + if reg_lines: + print(f'{registry} に同じ番号を払い出しました。') + else: + print(f'注意: {registry} が無いため、番号の払い出しを記録していません。') print(' 次に: TODO の列をソースを読んで埋め、') print(' test_coverage.py → prioritize.py → build_checklist.py を実行') else: diff --git a/tools/api-inventory/scripts/merge.py b/tools/api-inventory/scripts/merge.py index e611b39776..930c1471b0 100644 --- a/tools/api-inventory/scripts/merge.py +++ b/tools/api-inventory/scripts/merge.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- -"""out/*.tsv をマージ・整形・採番して 1本の TSV にする。""" +"""out/*.tsv をマージ・整形・採番して 1本の TSV にする。 + +**台帳の初回生成専用。** 全行に `no` を振り直すので、既存の台帳には使わない +(`no` は主キーで、一度振った番号は変えない)。既存の台帳に行を足すのは add_row.py。 +""" import glob, os, sys NCOL = 41 diff --git a/tools/api-inventory/scripts/paths.py b/tools/api-inventory/scripts/paths.py index d67d7f8b5a..3b38286e65 100644 --- a/tools/api-inventory/scripts/paths.py +++ b/tools/api-inventory/scripts/paths.py @@ -11,6 +11,7 @@ weko3_api_list.tsv 台帳(24列) api_snapshot.json 経路のベースライン reconcile_allow.json 実機に無い行の許可リスト + no_registry.tsv no(主キー)の払い出し記録。廃止した番号も残す """ import os import sys diff --git a/tools/api-inventory/tests/test_add_row.py b/tools/api-inventory/tests/test_add_row.py new file mode 100644 index 0000000000..713f952c63 --- /dev/null +++ b/tools/api-inventory/tests/test_add_row.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""add_row.py — 新しい行に振る `no`。 + +`no` は台帳の主キーで、一度振った番号は変えず、使い回さない。消した行の番号は +`no_registry.tsv` に `廃止` として残るので、台帳だけを見て「最大値 + 1」にすると、 +末尾の行を廃止した直後にその番号をもう一度振ってしまう。 +""" +import json +import os +import subprocess +import sys + +import add_row +from conftest import FULL_HEADER, make_row, run + +REG_HEADER = '\t'.join(add_row.REGISTRY_COLUMNS) + + +def _setup(tmp_path, full_nos, registry=None): + full = tmp_path / 'weko3_api_list_full.tsv' + lines = ['\t'.join(FULL_HEADER)] + for n in full_nos: + r = make_row(no=str(n), uri=f'/demo{n}', endpoint=f'demo.v{n}') + lines.append('\t'.join(r[h] for h in FULL_HEADER)) + full.write_text('\n'.join(lines) + '\n', encoding='utf-8') + if registry is not None: + (tmp_path / 'no_registry.tsv').write_text( + '\n'.join([REG_HEADER] + registry) + '\n', encoding='utf-8') + snap = tmp_path / 'api_snapshot.json' + snap.write_text(json.dumps({'endpoints': {'ui:demo.new': { + 'app': 'ui', 'endpoint': 'demo.new', 'view': 'weko_demo.views.new', + 'routes': [{'rule': '/demo/new', 'methods': ['GET']}]}}}), encoding='utf-8') + return full, snap + + +def _append(tmp_path, full, snap): + return run('add_row.py', '--endpoint', 'ui:demo.new', '--append', + '--full', full, '--snapshot', snap, '--weko-root', tmp_path, expect=0) + + +def _last(path): + return open(path, encoding='utf-8').read().rstrip('\n').split('\n')[-1].split('\t') + + +def test_次の番号は払い出し記録と台帳の両方の最大値の次(tmp_path): + # 3 を廃止して台帳から消した直後。台帳だけ見ると 3 を振ってしまう + full, snap = _setup(tmp_path, [1, 2], registry=[ + '1\tUIアプリ\tGET\t/demo1\tdemo.v1\t現役\t', + '2\tUIアプリ\tGET\t/demo2\tdemo.v2\t現役\t', + '3\tUIアプリ\tGET\t/demo3\tdemo.v3\t廃止\t経路が消えた', + ]) + _append(tmp_path, full, snap) + assert _last(full)[0] == '4' + + +def test_追記した行を同じ番号で払い出し記録に書き足す(tmp_path): + full, snap = _setup(tmp_path, [1], registry=['1\tUIアプリ\tGET\t/demo1\tdemo.v1\t現役\t']) + _append(tmp_path, full, snap) + assert _last(tmp_path / 'no_registry.tsv') == \ + ['2', 'UIアプリ', 'GET', '/demo/new', 'demo.new', '現役', ''] + + +def test_表示だけなら払い出し記録を触らない(tmp_path): + full, snap = _setup(tmp_path, [1], registry=['1\tUIアプリ\tGET\t/demo1\tdemo.v1\t現役\t']) + before = (tmp_path / 'no_registry.tsv').read_text(encoding='utf-8') + run('add_row.py', '--endpoint', 'ui:demo.new', + '--full', full, '--snapshot', snap, '--weko-root', tmp_path, expect=0) + assert (tmp_path / 'no_registry.tsv').read_text(encoding='utf-8') == before + + +def test_払い出し記録が無ければ台帳の最大値の次で注意を出す(tmp_path): + full, snap = _setup(tmp_path, [1, 5]) + p = _append(tmp_path, full, snap) + assert _last(full)[0] == '6' + assert '払い出しを記録していません' in p.stdout + assert not (tmp_path / 'no_registry.tsv').exists() + + +def test_払い出し記録のヘッダが違えば書き込まない(tmp_path): + full, snap = _setup(tmp_path, [1], registry=[]) + (tmp_path / 'no_registry.tsv').write_text('no\turi\n1\t/demo1\n', encoding='utf-8') + before = full.read_text(encoding='utf-8') + p = run('add_row.py', '--endpoint', 'ui:demo.new', '--append', + '--full', full, '--snapshot', snap, '--weko-root', tmp_path) + assert p.returncode != 0 + assert full.read_text(encoding='utf-8') == before + + +def test_払い出し記録に書けなければ台帳を触らない(tmp_path): + """記録を先に書く。逆順だと、台帳にだけある記録漏れの番号が残る。""" + full, snap = _setup(tmp_path, [1], registry=['1\tUIアプリ\tGET\t/demo1\tdemo.v1\t現役\t']) + reg = tmp_path / 'no_registry.tsv' + reg.chmod(0o444) + if os.access(reg, os.W_OK): # root では読み取り専用にならない + import pytest + pytest.skip('読み取り専用のファイルに書けてしまう環境') + before = full.read_text(encoding='utf-8') + p = run('add_row.py', '--endpoint', 'ui:demo.new', '--append', + '--full', full, '--snapshot', snap, '--weko-root', tmp_path) + assert p.returncode != 0 + assert full.read_text(encoding='utf-8') == before + + +def test_並行して追記しても同じ番号を二度払い出さない(tmp_path): + full, snap = _setup(tmp_path, [1], registry=['1\tUIアプリ\tGET\t/demo1\tdemo.v1\t現役\t']) + e = {k: v for k, v in os.environ.items() if k != 'WEKO_API_INVENTORY_DIR'} + cmd = [sys.executable, os.path.join(os.path.dirname(add_row.__file__), 'add_row.py'), + '--endpoint', 'ui:demo.new', '--append', + '--full', str(full), '--snapshot', str(snap), '--weko-root', str(tmp_path)] + procs = [subprocess.Popen(cmd, env=e, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + for _ in range(8)] + assert all(p.wait() == 0 for p in procs) + nos = [l.split('\t')[0] for l in full.read_text(encoding='utf-8').rstrip('\n').split('\n')[1:]] + assert sorted(nos, key=int) == [str(i) for i in range(1, 10)] + reg_nos = [l.split('\t')[0] for l in + (tmp_path / 'no_registry.tsv').read_text(encoding='utf-8').rstrip('\n').split('\n')[1:]] + assert reg_nos == [str(i) for i in range(1, 10)] + + +def test_台帳が無ければ作らずに止まる(tmp_path): + """--full の打ち間違いで、空の台帳を黙って作って書き込まない。""" + full, snap = _setup(tmp_path, [1]) + missing = tmp_path / 'typo.tsv' + p = run('add_row.py', '--endpoint', 'ui:demo.new', '--append', + '--full', missing, '--snapshot', snap, '--weko-root', tmp_path) + assert p.returncode != 0 + assert not missing.exists()