From c28dc03aab2c20615a54ff6e96f5d3ecee356d3c Mon Sep 17 00:00:00 2001 From: Wei18 <11254896+Wei18@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:18:29 +0800 Subject: [PATCH 1/3] test: guard artifact upload/download action version pairing Self-audit issue #25 flagged upload-artifact@v7 vs download-artifact@v8 across the discovery -> reviewer -> synthesis -> report pipeline as a high-severity drift risk, since these companion actions have had format-incompatible major bumps before (v3->v4). Verified compatible: v8's release notes (ESM migration, digest-mismatch defaults to error, Content-Type-driven unzip) don't change the artifact format v7 upload produces, and the 2026-07-20 self-audit run exercised upload@v7 + download@v8 across the full pipeline successfully. So this keeps the current pins and adds a regression test instead of downgrading download-artifact. --- test/action-pins.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/action-pins.test.ts diff --git a/test/action-pins.test.ts b/test/action-pins.test.ts new file mode 100644 index 0000000..b4f193a --- /dev/null +++ b/test/action-pins.test.ts @@ -0,0 +1,74 @@ +// test/action-pins.test.ts +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)).replace(/\/$/, ''); + +// actions/upload-artifact and actions/download-artifact are companion actions +// whose artifact storage format is tied to their major version (the v3->v4 +// bump was a documented breaking change). This repo's discovery -> reviewer -> +// synthesis -> report pipeline crosses that boundary on every produce->consume +// hand-off, so upload and download majors must be pinned in lockstep. +// +// Verified compatible: v8 download-artifact release notes (ESM migration, +// digest-mismatch defaults to error, unzip decided by Content-Type) do not +// change the artifact format v7 upload-artifact produces, and the 2026-07-20 +// self-audit run (github.com/wei18/Upkeep/actions/runs/29782318165) exercised +// upload@v7 + download@v8 across the full pipeline successfully. +// +// If Dependabot bumps one side without the other, re-verify compatibility via +// the target action's release notes before updating this constant. +const ALLOWED_PAIR = { upload: 'v7', download: 'v8' }; + +const ACTION_YML_FILES = [ + join(ROOT, 'action.yml'), + join(ROOT, '.github/actions/discovery/action.yml'), + join(ROOT, '.github/actions/reviewer/action.yml'), + join(ROOT, '.github/actions/synthesis/action.yml'), + join(ROOT, '.github/actions/report/action.yml'), +]; + +function collectSteps(actionYmlPath: string): any[] { + const doc = parse(readFileSync(actionYmlPath, 'utf8')); + return doc.runs?.steps ?? []; +} + +function extractPins(prefix: 'actions/upload-artifact@' | 'actions/download-artifact@'): string[] { + const pins: string[] = []; + for (const file of ACTION_YML_FILES) { + for (const step of collectSteps(file)) { + if (typeof step.uses === 'string' && step.uses.startsWith(prefix)) { + pins.push(step.uses.slice(prefix.length)); + } + } + } + return pins; +} + +describe('artifact action version pinning', () => { + it('finds upload-artifact and download-artifact references to check', () => { + // Guards against the scan silently finding nothing (e.g. a renamed action). + expect(extractPins('actions/upload-artifact@').length).toBeGreaterThan(0); + expect(extractPins('actions/download-artifact@').length).toBeGreaterThan(0); + }); + + it('pins actions/upload-artifact to the same version everywhere', () => { + const versions = new Set(extractPins('actions/upload-artifact@')); + expect(versions).toEqual(new Set([ALLOWED_PAIR.upload])); + }); + + it('pins actions/download-artifact to the same version everywhere', () => { + const versions = new Set(extractPins('actions/download-artifact@')); + expect(versions).toEqual(new Set([ALLOWED_PAIR.download])); + }); + + it('pairs upload/download versions to the verified-compatible combination', () => { + const uploadVersions = new Set(extractPins('actions/upload-artifact@')); + const downloadVersions = new Set(extractPins('actions/download-artifact@')); + expect([...uploadVersions]).toEqual([ALLOWED_PAIR.upload]); + expect([...downloadVersions]).toEqual([ALLOWED_PAIR.download]); + }); +}); From ef654eeea3343557d5c42ebd60d3d783451cfcae Mon Sep 17 00:00:00 2001 From: Wei18 <11254896+Wei18@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:18:32 +0800 Subject: [PATCH 2/3] fix: add missing always() guard to docs_staleness reviewer step The other six reviewer steps in the Marketplace action.yml use `if: always() && contains(...)` so each reviewer runs regardless of discovery's outcome. docs_staleness was missing `always() &&`, so a bare if implicitly depended on success(), breaking that uniform contract (issue #25). --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 832ddd5..7e12078 100644 --- a/action.yml +++ b/action.yml @@ -40,7 +40,7 @@ runs: - id: discovery uses: wei18/upkeep/.github/actions/discovery@v2 - - if: contains(fromJSON(steps.discovery.outputs.reviewers), 'docs_staleness') + - if: always() && contains(fromJSON(steps.discovery.outputs.reviewers), 'docs_staleness') continue-on-error: true uses: wei18/upkeep/.github/actions/reviewer@v2 with: From f171b7acf5eb439ac052b1ea2281c047dc9546d6 Mon Sep 17 00:00:00 2001 From: Wei18 <11254896+Wei18@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:18:33 +0800 Subject: [PATCH 3/3] docs: record src/ comment-language convention as English, translate zh comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-audit issue #25 found src/ comments split roughly evenly between Traditional Chinese and English with no recorded convention. Records English (matching README.md's en base) in CLAUDE.md and translates the remaining Traditional-Chinese comments across src/*.ts to English. Comment text only — no behavioral changes. --- CLAUDE.md | 1 + src/classify.ts | 8 ++++---- src/consolidate.ts | 9 +++++---- src/discovery.ts | 4 ++-- src/find-issue.ts | 4 ++-- src/gitmeta.ts | 23 +++++++++++++---------- src/issue.ts | 2 +- src/matrix.ts | 2 +- src/rubric.ts | 16 ++++++++-------- src/types.ts | 30 +++++++++++++++--------------- 10 files changed, 52 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c19b9f1..3f0d980 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,3 +45,4 @@ Root `README.md` is the en base; `docs/{zh-TW,zh-CN,ja,ko}/` each mirror `README - Degradation over crashing: malformed reviewer/JSON input becomes `{status: 'failed', findings: []}` via the finalize helpers in `src/report.ts` — follow that pattern for new parsers. - Specs go in `docs/superpowers/specs/`, plans in `docs/superpowers/plans/` (dated, kebab-case). - Before relying on external tool behavior (plugin schema, CLI flags), verify against official docs — no trial-and-error reverse engineering. +- In-source comments in `src/` are English only, matching `README.md`'s en base. diff --git a/src/classify.ts b/src/classify.ts index 21e4142..9fa51b0 100644 --- a/src/classify.ts +++ b/src/classify.ts @@ -30,12 +30,12 @@ export function classify(path: string, content: Buffer): { modality: Modality; c const segs = lower.split('/'); const isSpecPath = segs.some((s) => s === 'spec' || s === 'specs'); let category: Category; - // icon 名稱規則僅適用於影像資產,避免 visual_icon.md 等文字檔被誤分類 + // The icon naming rule only applies to image assets, so text files like visual_icon.md aren't misclassified if (ext === '.icns' || ext === '.ico' || ((RASTER.has(ext) || ext === '.svg') && name.includes('icon'))) category = 'icon'; else if (modality === 'raster_image') category = 'visual'; - else if (isSpecPath) category = 'spec'; // 路徑含 spec/specs 區段;避免 *.spec.ts 誤判 - else if (/(?:^|[-_])flow(?:[-_.]|$)/.test(name)) category = 'flow'; // 明確 flow 命名(任何副檔名) - else if (ext === '.svg') category = 'visual'; // 通用向量圖形=設計資產(design §2 → visual_icon);.mmd/.dot/.puml 才是圖表語言 + else if (isSpecPath) category = 'spec'; // path contains a spec/specs segment; avoids misjudging *.spec.ts + else if (/(?:^|[-_])flow(?:[-_.]|$)/.test(name)) category = 'flow'; // explicit flow naming (any extension) + else if (ext === '.svg') category = 'visual'; // generic vector graphics = design assets (design §2 -> visual_icon); .mmd/.dot/.puml are diagram languages instead else if (modality === 'vector_diagram') category = 'flow'; else if (CODE.has(ext)) category = 'code'; else if (DOC.has(ext)) category = 'doc'; diff --git a/src/consolidate.ts b/src/consolidate.ts index 6358341..2575b90 100644 --- a/src/consolidate.ts +++ b/src/consolidate.ts @@ -38,8 +38,9 @@ export function consolidate( if (arr) arr.push(fnd); else groups.set(key, [fnd]); } - // synthesis 判定的語意重複(鍵格式 "reviewer|file|category")再合併一輪; - // 去掉 reviewer 前綴即得本層的 file|category 群組鍵,查無對應群組的鍵忽略 + // Merge once more using the semantic duplicates synthesis identified (key format + // "reviewer|file|category"); stripping the reviewer prefix gives this layer's + // file|category group key, and keys with no matching group are ignored. if (synthesis !== null && synthesis.status === 'ok') { for (const dup of synthesis.semantic_duplicates) { const keys = uniq(dup.map((k) => k.slice(k.indexOf('|') + 1))).filter((k) => groups.has(k)); @@ -51,7 +52,7 @@ export function consolidate( const merged: ConsolidatedFinding[] = []; for (const group of groups.values()) { - // 代表:severity×confidence 最高;同分時依 reviewer 列舉序(design §4),不靠載入順序 + // Representative: highest severity x confidence; ties break by reviewer enumeration order (design §4), not load order const rep = [...group].sort((a, b) => cmp(a, b) || (REVIEWER_RANK[a.reviewer] - REVIEWER_RANK[b.reviewer]))[0]; merged.push({ ...rep, @@ -61,7 +62,7 @@ export function consolidate( } merged.sort((a, b) => cmp(a, b) || a.file.localeCompare(b.file)); - // synthesis 只在 status === 'ok' 才採用;用 if 讓 TS 正確 narrow synthesis 非空 + // synthesis is only adopted when status === 'ok'; using an if lets TS correctly narrow synthesis to non-null let themes: Theme[] = []; let executiveSummary = ''; if (synthesis !== null && synthesis.status === 'ok') { diff --git a/src/discovery.ts b/src/discovery.ts index 5c6513b..b05efe5 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -18,7 +18,7 @@ function discoverConventions(repoRoot: string, paths: string[]): ConventionSourc }; add('CLAUDE.md', 'claude_md'); add('.claude/audit.yml', 'audit_yml'); - // 目錄型來源從已列出的 paths 前綴過濾(重用,不再呼叫一次 git ls-files) + // Directory-based sources are filtered by prefix from the already-listed paths (reused, so we avoid another git ls-files call) for (const f of paths) { if (f.startsWith('.claude/skills/')) out.push({ path: f, kind: 'skill' }); else if (f.startsWith('.claude/workflows/')) out.push({ path: f, kind: 'workflow' }); @@ -37,7 +37,7 @@ export function discover(repoRoot: string): Inventory { try { content = readFileSync(join(repoRoot, p)); } catch { - return []; // 跳過無法當檔讀的項目:submodule gitlink、目錄、損壞 symlink + return []; // skip entries that can't be read as a file: submodule gitlink, directory, broken symlink } const { modality, category } = classify(p, content); return [{ path: p, content, modality, category }]; diff --git a/src/find-issue.ts b/src/find-issue.ts index 88f19fc..3721401 100644 --- a/src/find-issue.ts +++ b/src/find-issue.ts @@ -4,7 +4,7 @@ import { readJsonOrNull } from './finalize.js'; import { ISSUE_MARKER } from './report-issue.js'; import type { IssueRef } from './issue.js'; -// 畸形輸入(缺檔、壞 JSON、非陣列)一律降級為「找不到」→ 空字串 +// Malformed input (missing file, bad JSON, non-array) always degrades to "not found" -> empty string export function findIssueNumber(input: unknown): string { if (!Array.isArray(input)) return ''; const n = findMarkedIssue(input as IssueRef[], ISSUE_MARKER); @@ -12,7 +12,7 @@ export function findIssueNumber(input: unknown): string { } // CLI: find-issue.ts -// 檔內容為 `gh issue list --json number,body` 的 JSON 陣列;印出帶 marker 的 number(無則印空字串) +// File content is the JSON array from `gh issue list --json number,body`; prints the number carrying the marker (empty string if none) if (import.meta.url === `file://${process.argv[1]}`) { process.stdout.write(findIssueNumber(readJsonOrNull(process.argv[2]))); } diff --git a/src/gitmeta.ts b/src/gitmeta.ts index c7ed5bc..8255a7a 100644 --- a/src/gitmeta.ts +++ b/src/gitmeta.ts @@ -1,9 +1,10 @@ // src/gitmeta.ts import { execFileSync } from 'node:child_process'; -// 對每組路徑查最後 commit 的 committer ISO 時間;無記錄回 null。 -// 單次 git log 走訪(reverse-chronological,與 scan.ts/discovery.ts 一致的批次風格): -// 每個路徑第一次出現即為其最新 commit,避免 per-file 的 O(N) 子行程。 +// Looks up each path's last-commit committer ISO time; null when there's no record. +// A single git log traversal (reverse-chronological, matching the batch style of +// scan.ts/discovery.ts): a path's first occurrence is its most recent commit, +// avoiding an O(N) per-file subprocess. export function lastCommitTimes(repoRoot: string, paths: string[]): Map { const m = new Map(); for (const p of paths) m.set(p, null); @@ -13,17 +14,19 @@ export function lastCommitTimes(repoRoot: string, paths: string[]): Map 0 && cur !== null && m.get(line) === null) { - // 第一次出現 = 最新 commit;後續較舊的出現不覆蓋。 + // First occurrence = most recent commit; later (older) occurrences don't overwrite it. m.set(line, cur); } } diff --git a/src/issue.ts b/src/issue.ts index 8d9b64c..aca55c8 100644 --- a/src/issue.ts +++ b/src/issue.ts @@ -4,7 +4,7 @@ export interface IssueRef { body: string; } -// 回傳第一個 body 含 marker 的 issue number;無則 null。 +// Returns the number of the first issue whose body contains the marker; null if none. export function findMarkedIssue(issues: IssueRef[], marker: string): number | null { const hit = issues.find((i) => typeof i.body === 'string' && i.body.includes(marker)); return hit ? hit.number : null; diff --git a/src/matrix.ts b/src/matrix.ts index cb31f35..2d1691b 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -8,7 +8,7 @@ export function enabledReviewers(config: AuditConfig): ReviewerName[] { } // CLI: matrix.ts [outFile] -// 印出 `reviewers=`(無 outFile→stdout;有→append,供 >> $GITHUB_OUTPUT) +// Prints `reviewers=` (no outFile -> stdout; with one -> appends, for >> $GITHUB_OUTPUT) if (import.meta.url === `file://${process.argv[1]}`) { const repoRoot = process.argv[2] ?? process.cwd(); const line = `reviewers=${JSON.stringify(enabledReviewers(loadConfig(repoRoot)))}\n`; diff --git a/src/rubric.ts b/src/rubric.ts index f05369d..ddddb48 100644 --- a/src/rubric.ts +++ b/src/rubric.ts @@ -4,7 +4,7 @@ import type { Inventory, ReviewerName, Category } from './types.js'; const ALL: Category[] = ['code', 'doc', 'spec', 'visual', 'flow', 'icon', 'config', 'other']; -// 每個 reviewer 負責的 file 類別(target 選擇用) +// File categories each reviewer is responsible for (used for target selection) const DOMAINS: Record = { docs_staleness: ['doc'], code_hygiene: ['code'], @@ -12,19 +12,19 @@ const DOMAINS: Record = { visual_icon: ['visual', 'icon'], duplicate_orphan: ALL, convention: ALL, - i18n: [], // i18n 只管 code 層在地化字串(design §2/§2.1),無對應 file category;target 由 DEFAULT_PATHS 的 glob 決定,不與 docs_staleness 的多語 doc 範疇重疊 + i18n: [], // i18n only covers code-level localization strings (design §2/§2.1), with no corresponding file category; targets are decided by the DEFAULT_PATHS globs and don't overlap docs_staleness's multi-language doc scope }; -// 無 category 對應的 reviewer 之預設 target globs(audit.yml 的 reviewers..paths 優先) +// Default target globs for reviewers with no category mapping (audit.yml's reviewers..paths takes precedence) const DEFAULT_PATHS: Partial> = { i18n: ['**/*.lproj/**', '**/*.strings', '**/*.stringsdict', '**/*.xcstrings', '**/locales/**', '**/i18n/**'], }; export interface RubricBundle { - builtinRubric: string; // action 內建 rubric 檔絕對路徑 - conventionSources: string[]; // repo 規範來源(相對路徑) - explicitRubric: string | null; // audit.yml reviewers..rubric 或 null - targetFiles: string[]; // 此 reviewer 要看的檔(相對路徑) + builtinRubric: string; // absolute path to the action's built-in rubric file + conventionSources: string[]; // repo convention-source files (relative paths) + explicitRubric: string | null; // audit.yml reviewers..rubric, or null + targetFiles: string[]; // files this reviewer should look at (relative paths) } function globToRegex(glob: string): RegExp { @@ -34,7 +34,7 @@ function globToRegex(glob: string): RegExp { if (c === '*') { if (glob[i + 1] === '*') { i++; - // `**/` 應錨在路徑區段邊界(含零段),避免 `**/README.md` 誤中 `xREADME.md` + // `**/` should anchor on a path-segment boundary (including zero segments), so `**/README.md` doesn't wrongly match `xREADME.md` if (glob[i + 1] === '/') { re += '(?:.*/)?'; i++; } else re += '.*'; } else { diff --git a/src/types.ts b/src/types.ts index f77161d..42fbafb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,7 +23,7 @@ export type Category = export interface ReviewerConfig { enabled: boolean; paths?: string[]; - rubric?: string; // repo 內相對路徑 + rubric?: string; // path relative to repo root } export interface AuditConfig { @@ -34,18 +34,18 @@ export interface AuditConfig { } export interface FileEntry { - path: string; // 相對 repo root,POSIX 分隔 + path: string; // relative to repo root, POSIX separators modality: Modality; category: Category; sizeBytes: number; - hash: string; // sha256 hex;binary 也算 - oversizedText: boolean; // 文字類且 > MAX_FILE_KB - lastCommitISO: string | null; // 無 git 記錄為 null - referencedBy: string[]; // 哪些檔在內文以路徑 token 提及此檔 basename(word-boundary;TS 源也認 .js specifier) + hash: string; // sha256 hex; computed for binary too + oversizedText: boolean; // text-like and > MAX_FILE_KB + lastCommitISO: string | null; // null when there's no git record + referencedBy: string[]; // files whose body mentions this file's basename as a path token (word-boundary; TS sources also match a .js specifier) } export interface ConventionSource { - path: string; // 探索到的規範來源檔 + path: string; // discovered convention-source file kind: 'claude_md' | 'skill' | 'workflow' | 'gha_workflow' | 'audit_yml'; } @@ -70,8 +70,8 @@ export const SSOT_DIRECTIONS = ['stale_a', 'stale_b', 'uncertain', 'n/a'] as con export type SsotDirection = typeof SSOT_DIRECTIONS[number]; export interface Finding { - file: string; // 主體檔(跨檔問題放主檔) - related: string[]; // 關聯檔(可空陣列) + file: string; // primary file (cross-file issues go under the primary) + related: string[]; // related files (may be an empty array) reviewer: ReviewerName; category: FindingCategory; problem: string; @@ -84,27 +84,27 @@ export interface Finding { export interface ReviewerOutput { reviewer: ReviewerName; - status: 'ok' | 'failed'; // failed 時 findings 必為空 + status: 'ok' | 'failed'; // findings must be empty when failed findings: Finding[]; } export interface Theme { title: string; narrative: string; - related_files: string[]; // 此主題涵蓋的檔路徑 + related_files: string[]; // file paths covered by this theme priority: Severity; } export interface SynthesisOutput { themes: Theme[]; - semantic_duplicates: string[][]; // 每組為 "reviewer|file|category" 鍵 + semantic_duplicates: string[][]; // each group is a set of "reviewer|file|category" keys executive_summary: string; - status: 'ok' | 'failed'; // failed 時 themes 必為空 + status: 'ok' | 'failed'; // themes must be empty when failed } export interface ConsolidatedFinding extends Finding { - // 繼承的 `reviewer`(單數)= 代表 finding 的擁有者;顯示一律用 `reviewers`(聯集) - reviewers: ReviewerName[]; // 回報此 file+category 的所有 reviewer(聯集) + // Inherited `reviewer` (singular) = the owner of the representative finding; display always uses `reviewers` (the union) + reviewers: ReviewerName[]; // all reviewers that reported this file+category (union) } export interface ReportStats {