Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions src/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 5 additions & 4 deletions src/consolidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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,
Expand All @@ -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') {
Expand Down
4 changes: 2 additions & 2 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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 }];
Expand Down
4 changes: 2 additions & 2 deletions src/find-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ 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);
return n === null ? '' : String(n);
}

// CLI: find-issue.ts <issuesJsonFile>
// 檔內容為 `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])));
}
23 changes: 13 additions & 10 deletions src/gitmeta.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null> {
const m = new Map<string, string | null>();
for (const p of paths) m.set(p, null);
Expand All @@ -13,25 +14,27 @@ export function lastCommitTimes(repoRoot: string, paths: string[]): Map<string,
try {
out = execFileSync(
'git',
// core.quotePath=false 讓非 ASCII 路徑保持原樣,與 --name-only 輸出可比對。
// `-- ...paths` 把 --name-only 輸出限縮在這批路徑內,不會冒出多餘 key。
// --diff-merges=first-parent:讓 merge commit 也列出檔案(對 first parent 的 diff),
// 保留舊版 per-file `git log -1` 對 merge-only 變更的歸屬,不致漏判。
// 不用 --follow:git 不支援多路徑 + --follow,且舊版 per-file 行為同樣不追 rename。
// core.quotePath=false keeps non-ASCII paths as-is, so they match the --name-only output.
// `-- ...paths` limits the --name-only output to this batch of paths, avoiding stray keys.
// --diff-merges=first-parent: makes merge commits list their files too (diff against the
// first parent), preserving the old per-file `git log -1` attribution for merge-only
// changes so nothing is missed.
// No --follow: git doesn't support multiple paths + --follow, and the old per-file
// behavior didn't track renames either.
['-c', 'core.quotePath=false', 'log', '--diff-merges=first-parent',
'--format=\x1e%cI', '--name-only', '--', ...paths],
{ cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
);
} catch {
return m; // git 失敗時全部維持 null
return m; // leave everything null if git fails
}

let cur: string | null = null;
for (const line of out.split('\n')) {
if (line.startsWith('\x1e')) {
cur = line.slice(1).trim() || null;
} else if (line.length > 0 && cur !== null && m.get(line) === null) {
// 第一次出現 = 最新 commit;後續較舊的出現不覆蓋。
// First occurrence = most recent commit; later (older) occurrences don't overwrite it.
m.set(line, cur);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function enabledReviewers(config: AuditConfig): ReviewerName[] {
}

// CLI: matrix.ts <repoRoot> [outFile]
// 印出 `reviewers=<json-array>`(無 outFilestdout;有→append,供 >> $GITHUB_OUTPUT
// Prints `reviewers=<json-array>` (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`;
Expand Down
16 changes: 8 additions & 8 deletions src/rubric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,27 @@ 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<ReviewerName, Category[]> = {
docs_staleness: ['doc'],
code_hygiene: ['code'],
spec_flow: ['spec', 'flow'],
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.ymlreviewers.<name>.paths 優先)
// Default target globs for reviewers with no category mapping (audit.yml's reviewers.<name>.paths takes precedence)
const DEFAULT_PATHS: Partial<Record<ReviewerName, string[]>> = {
i18n: ['**/*.lproj/**', '**/*.strings', '**/*.stringsdict', '**/*.xcstrings', '**/locales/**', '**/i18n/**'],
};

export interface RubricBundle {
builtinRubric: string; // action 內建 rubric 檔絕對路徑
conventionSources: string[]; // repo 規範來源(相對路徑)
explicitRubric: string | null; // audit.yml reviewers.<name>.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.<name>.rubric, or null
targetFiles: string[]; // files this reviewer should look at (relative paths)
}

function globToRegex(glob: string): RegExp {
Expand All @@ -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 {
Expand Down
30 changes: 15 additions & 15 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,18 +34,18 @@ export interface AuditConfig {
}

export interface FileEntry {
path: string; // 相對 repo rootPOSIX 分隔
path: string; // relative to repo root, POSIX separators
modality: Modality;
category: Category;
sizeBytes: number;
hash: string; // sha256 hexbinary 也算
oversizedText: boolean; // 文字類且 > MAX_FILE_KB
lastCommitISO: string | null; // 無 git 記錄為 null
referencedBy: string[]; // 哪些檔在內文以路徑 token 提及此檔 basenameword-boundaryTS 源也認 .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';
}

Expand All @@ -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;
Expand All @@ -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 {
Expand Down
74 changes: 74 additions & 0 deletions test/action-pins.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});