项目文件夹

文件
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

344 行
14 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/**
* set-status.mjs — canonical CLI to update a tracker row's status/note (#1428).
*
* data/applications.md is a shared surface with multiple readers and writers.
* One canonical write path is safer than N agents hand-editing markdown, so
* modes (apply Step 9, followup, batch) call this instead of editing the table.
*
* Usage:
* node set-status.mjs <report#|company> <state> [--note "..."] [--role "..."] [--dry-run] [--json]
*
* Row resolution:
* - numeric argument → exact match on the # column; if the tracker has a
* duplicate # (see #1704 — merge-tracker.mjs bug, now fixed, that could
* assign the same # to two rows), --role narrows it, otherwise it fails
* ambiguous with a candidate list instead of silently editing whichever
* row was found first
* - otherwise → company match (normalized, same key as merge-tracker dedup);
* multiple hits are narrowed with --role (fuzzy, role-matcher.mjs), and
* anything still ambiguous fails with a numbered candidate list.
*
* State validation is strict against templates/states.yml (labels, ids, and
* aliases resolve to the canonical label; anything else is rejected before the
* tracker is touched). --note appends to the Notes cell with "; " and is
* idempotent — re-running the same command is always safe.
*
* The read-modify-write runs under the shared tracker lock (tracker-utils.mjs,
* same lock as merge-tracker.mjs) and the file is replaced atomically. Only the
* Status and Notes cells of the matched row change; every other byte of the
* tracker round-trips untouched.
*
* Exit codes: 0 success (including no-op re-runs) · 1 usage error,
* non-canonical state, unreadable states.yml, or non-retryable lock/write failure ·
* 2 row not found or unreadable tracker · 3 ambiguous company match ·
* 4 tracker lock timeout (busy — retry later).
*
* When the new status is Applied, the JSON output carries
* `"followupSeedCandidate": true` — the hook point for seeding
* data/follow-ups.md with the default cadence (#1430, not implemented here).
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { roleFuzzyMatch } from './role-matcher.mjs';
import {
rebuildRow, resolveTrackerPath, trackerLockDirFor, acquireTrackerLock,
writeFileAtomic, loadCanonicalStates, resolveCanonicalState, normalizeCompany, cell,
} from './tracker-utils.mjs';
const CAREER_OPS = dirname(fileURLToPath(import.meta.url));
const STATES_FILE = join(CAREER_OPS, 'templates/states.yml');
const EXIT_OK = 0;
const EXIT_USAGE = 1;
const EXIT_NOT_FOUND = 2;
const EXIT_AMBIGUOUS = 3;
const EXIT_LOCK_TIMEOUT = 4;
const USAGE = `Usage: node set-status.mjs <report#|company> <state> [--note "..."] [--role "..."] [--dry-run] [--json]
<report#|company> Row selector: tracker # (exact) or company name (normalized match)
<state> Canonical state from templates/states.yml (aliases accepted)
--note "..." Append to the Notes cell ("; "-separated, idempotent)
--role "..." Disambiguate when several rows share the company (fuzzy match)
--dry-run Resolve and validate, but write nothing
--json Machine-readable output on stdout (errors included)`;
// ── argument parsing ─────────────────────────────────────────────
const rawArgs = process.argv.slice(2);
const positional = [];
const flags = { note: null, role: null, dryRun: false, json: false };
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a === '--note' || a === '--role') {
// Never consume a following flag as the value: "--note --dry-run" would
// silently disable dry-run and turn a preview into a real write.
const value = rawArgs[i + 1];
if (value === undefined || value.startsWith('--')) {
failUsage(`Missing value for ${a}`);
}
flags[a === '--note' ? 'note' : 'role'] = value;
i++;
}
else if (a === '--dry-run') { flags.dryRun = true; }
else if (a === '--json') { flags.json = true; }
else if (a.startsWith('--')) { failUsage(`Unknown flag: ${a}`); }
else { positional.push(a); }
}
if (positional.length !== 2) {
failUsage(positional.length === 0 ? null : `Expected 2 arguments (selector, state), got ${positional.length}`);
}
const [selector, stateInput] = positional;
/**
* Emit a structured error and exit.
*
* With --json the error object goes to stdout so callers parse one stream; the
* human-readable message always goes to stderr.
*
* @param {number} exitCode - Process exit code (see EXIT_* contract above).
* @param {string} code - Stable machine-readable error code.
* @param {string} message - Human-readable explanation.
* @param {object} [extra] - Extra JSON fields (e.g. candidates).
* @returns {never}
*/
function failWith(exitCode, code, message, extra = {}) {
if (flags.json) {
console.log(JSON.stringify({ error: message, code, ...extra }));
}
console.error(`❌ ${message}`);
process.exit(exitCode);
}
/**
* Print usage (plus an optional specific complaint) and exit 1.
*
* With --json a structured usage-error payload goes to stdout (same shape as
* failWith) so machine callers always parse one stream. failUsage can fire
* mid-argv-parse — before flags.json is settled — so JSON mode is detected
* from the raw argv directly.
*
* @param {string|null} message - What was wrong with the invocation, if known.
* @returns {never}
*/
function failUsage(message) {
const msg = message ?? 'Expected 2 arguments: <report#|company> <state>';
if (rawArgs.includes('--json')) {
console.log(JSON.stringify({ error: msg, code: 'usage' }));
console.error(`❌ ${msg}`);
} else {
if (message) console.error(`❌ ${message}\n`);
console.error(USAGE);
}
process.exit(EXIT_USAGE);
}
// ── state validation (before anything touches the tracker) ──────
let states;
try {
states = loadCanonicalStates(STATES_FILE);
} catch (err) {
failWith(EXIT_USAGE, 'states-error', `Cannot load canonical states from ${STATES_FILE}: ${err.message}`);
}
const newStatus = resolveCanonicalState(stateInput, states);
if (!newStatus) {
const valid = states.map(s => s.label).join(' · ');
failWith(EXIT_USAGE, 'invalid-state', `"${stateInput}" is not a canonical state. Valid states: ${valid}`);
}
// ── tracker access ───────────────────────────────────────────────
const APPS_FILE = resolveTrackerPath(CAREER_OPS);
if (!existsSync(APPS_FILE)) {
failWith(EXIT_NOT_FOUND, 'no-tracker', `No tracker found at ${APPS_FILE}`);
}
/**
* Find the tracker row matching the CLI selector.
*
* @param {object[]} rows - Parsed data rows (parseTrackerRow output + lineIdx).
* @returns {object} The single matched row. Exits the process on 0 or 2+ matches.
*/
function resolveRow(rows) {
if (/^\d+$/.test(selector)) {
const num = parseInt(selector, 10);
let matches = rows.filter(r => r.num === num);
if (matches.length === 0) {
failWith(EXIT_NOT_FOUND, 'not-found', `No tracker row with #${num}`);
}
if (matches.length > 1 && flags.role) {
const narrowed = matches.filter(r => roleFuzzyMatch(r.role, flags.role));
if (narrowed.length === 1) return narrowed[0];
// Fall through with the original list so the candidates stay visible.
}
if (matches.length > 1) {
// A bare report number should never match more than one row — this is
// exactly the failure mode from #1704: a stale tracker # reused across
// 2+ rows means "the first match" is a silent coin flip on which
// company gets edited. Refuse to guess; require --role or the company
// selector instead.
const candidates = matches.map(r => ({ num: r.num, company: r.company, role: r.role }));
const listing = candidates.map(c => `#${c.num}\t${c.company}\t${c.role}`).join('\n');
failWith(EXIT_AMBIGUOUS, 'ambiguous',
`#${num} is a duplicate tracker number shared by ${matches.length} rows (see #1704) — pass --role to disambiguate, or use the company name instead:\n${listing}`,
{ candidates });
}
return matches[0];
}
const key = normalizeCompany(selector);
if (!key) failUsage(`Selector "${selector}" is empty after normalization`);
let matches = rows.filter(r => normalizeCompany(r.company) === key);
if (matches.length === 0) {
failWith(EXIT_NOT_FOUND, 'not-found', `No tracker row with company matching "${selector}"`);
}
if (matches.length > 1 && flags.role) {
const narrowed = matches.filter(r => roleFuzzyMatch(r.role, flags.role));
if (narrowed.length === 1) return narrowed[0];
// Fall through with the original list so the candidates stay visible.
}
if (matches.length > 1) {
const candidates = matches.map(r => ({ num: r.num, company: r.company, role: r.role }));
const listing = candidates.map(c => `#${c.num}\t${c.company}\t${c.role}`).join('\n');
failWith(EXIT_AMBIGUOUS, 'ambiguous',
`Company "${selector}" matches ${matches.length} rows — pass the # or narrow with --role:\n${listing}`,
{ candidates });
}
return matches[0];
}
// ── locked read-modify-write ─────────────────────────────────────
// Dry-run never writes, so it must not hold the exclusive lock: a read-only
// preview should not block (or be blocked by) merge-tracker or another
// set-status writer. A stale read is acceptable for a preview.
let lock = null;
if (!flags.dryRun) {
try {
lock = await acquireTrackerLock(trackerLockDirFor(APPS_FILE), {
timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
tracker: APPS_FILE,
});
} catch (err) {
// Exit 4 means "lock is busy — retry later" and must stay reserved for
// the actual timeout. Filesystem/configuration failures (EACCES on the
// lock dir, unwritable owner.json, …) are not retryable and fail as a
// config error instead.
if (err?.code === 'LOCK_TIMEOUT') {
failWith(EXIT_LOCK_TIMEOUT, 'lock-timeout', err.message);
}
failWith(EXIT_USAGE, 'lock-error', `Cannot acquire tracker lock: ${err.message}`);
}
}
// Safety net: failWith/failUsage/resolveRow call process.exit() directly and
// skip the explicit release below. release() is idempotent, so both firing
// on the happy path is fine.
if (lock) process.once('exit', () => lock.release());
let content;
try {
content = readFileSync(APPS_FILE, 'utf-8');
} catch (err) {
failWith(EXIT_NOT_FOUND, 'read-failure', `Cannot read tracker at ${APPS_FILE}: ${err.message}`);
}
const lines = content.split('\n');
const colmap = resolveColumns(lines);
const rows = [];
for (let i = 0; i < lines.length; i++) {
const row = parseTrackerRow(lines[i], colmap);
if (row) rows.push({ ...row, lineIdx: i });
}
if (rows.length === 0) {
failWith(EXIT_NOT_FOUND, 'empty-tracker', `Tracker at ${APPS_FILE} has no data rows`);
}
const target = resolveRow(rows);
const oldStatus = target.status;
const note = flags.note != null ? cell(flags.note) : null;
// Rebuild only the matched line: change the Status cell, append the note, keep
// every other cell exactly as parsed.
const parts = lines[target.lineIdx].split('|').map(s => s.trim());
while (parts.length <= Math.max(colmap.status, colmap.notes ?? 0)) parts.push('');
const statusChanged = parts[colmap.status] !== newStatus;
parts[colmap.status] = newStatus;
let noteChanged = false;
if (note) {
if (colmap.notes == null) {
failWith(EXIT_USAGE, 'no-notes-column', 'Tracker has no Notes column — cannot apply --note');
}
const existing = parts[colmap.notes] ?? '';
// Delimiter-aware idempotency: the note counts as already present only when
// it appears as a whole "; "-delimited entry (or as the entire field) — a
// bare substring of a longer entry ("sent" inside "sent CV") must not
// suppress a genuinely new note. Matching the full note text at entry
// boundaries (instead of splitting the field into segments) keeps retries
// idempotent even when the note itself contains "; ".
const hasNote = existing === note
|| existing.startsWith(`${note}; `)
|| existing.endsWith(`; ${note}`)
|| existing.includes(`; ${note}; `);
if (!hasNote) {
parts[colmap.notes] = existing && existing !== '—' && existing !== '-' ? `${existing}; ${note}` : note;
noteChanged = true;
}
}
const changed = statusChanged || noteChanged;
if (changed && !flags.dryRun) {
lines[target.lineIdx] = rebuildRow(parts);
try {
writeFileAtomic(APPS_FILE, lines.join('\n'));
} catch (err) {
// Same structured error contract as every other failure path — a raw
// stack trace on stdout/stderr would break --json consumers.
failWith(EXIT_USAGE, 'write-failure', `Cannot write tracker at ${APPS_FILE}: ${err.message}`);
}
}
lock?.release();
// ── report ───────────────────────────────────────────────────────
const result = {
changed,
num: target.num,
company: target.company,
role: target.role,
oldStatus,
newStatus,
...(note != null ? { note } : {}),
...(flags.dryRun ? { dryRun: true } : {}),
// Fire the #1430 hook only on an actual transition INTO Applied — an
// idempotent re-run of an already-Applied row must not invite a consumer
// to seed a duplicate follow-up.
...(statusChanged && newStatus === 'Applied' ? { followupSeedCandidate: true } : {}),
tracker: APPS_FILE,
};
if (flags.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const verb = flags.dryRun ? 'would set' : changed ? 'set' : 'already';
console.log(`✅ #${target.num} ${target.company}${target.role}: ${verb} ${oldStatus}${newStatus}${note ? ` (note: ${note})` : ''}`);
if (statusChanged && !flags.dryRun && newStatus === 'Applied') {
console.error('️ Status is Applied — consider seeding follow-ups in data/follow-ups.md (#1430: node followup-cadence.mjs)');
}
}
process.exit(EXIT_OK);