@opengsd/gsd-core 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -1
- package/.opencode/plugins/gsd-core.js +2 -3
- package/agents/gsd-code-fixer.compact.md +2 -2
- package/agents/gsd-code-fixer.md +4 -3
- package/agents/gsd-verifier.md +1 -1
- package/bin/install.js +422 -103
- package/gsd-core/bin/gsd-tools.cjs +106 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
- package/gsd-core/bin/lib/args-terminator.cjs +114 -0
- package/gsd-core/bin/lib/broken-windows.cjs +59 -1
- package/gsd-core/bin/lib/capability-lock.cjs +13 -2
- package/gsd-core/bin/lib/capability-registry.cjs +156 -74
- package/gsd-core/bin/lib/capability-state.cjs +14 -8
- package/gsd-core/bin/lib/capability-validator.cjs +17 -0
- package/gsd-core/bin/lib/config-loader.cjs +10 -0
- package/gsd-core/bin/lib/config.cjs +256 -58
- package/gsd-core/bin/lib/core-utils.cjs +34 -0
- package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +8 -3
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
- package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
- package/gsd-core/bin/lib/git-info-exclude.cjs +57 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +18 -3
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
- package/gsd-core/bin/lib/host-integration.cjs +13 -6
- package/gsd-core/bin/lib/init.cjs +65 -1
- package/gsd-core/bin/lib/install-engine.cjs +246 -32
- package/gsd-core/bin/lib/install-profiles.cjs +9 -4
- package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
- package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +113 -0
- package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
- package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
- package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
- package/gsd-core/bin/lib/milestone.cjs +43 -1
- package/gsd-core/bin/lib/model-catalog.cjs +8 -0
- package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
- package/gsd-core/bin/lib/phase-id.cjs +21 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +37 -4
- package/gsd-core/bin/lib/phase.cjs +115 -26
- package/gsd-core/bin/lib/planning-snapshot.cjs +3 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +18 -0
- package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +58 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +35 -2
- package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +12 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +39 -2
- package/gsd-core/bin/lib/roadmap.cjs +33 -25
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
- package/gsd-core/bin/lib/shell-command-projection.cjs +121 -16
- package/gsd-core/bin/lib/smart-entry.cjs +13 -0
- package/gsd-core/bin/lib/state-contract.cjs +29 -9
- package/gsd-core/bin/lib/state-document.cjs +12 -4
- package/gsd-core/bin/lib/state-transition.cjs +119 -24
- package/gsd-core/bin/lib/state.cjs +50 -28
- package/gsd-core/bin/lib/surface.cjs +9 -0
- package/gsd-core/bin/lib/task-command-router.cjs +5 -25
- package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
- package/gsd-core/bin/lib/verification.cjs +96 -9
- package/gsd-core/bin/lib/verify.cjs +2 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/git-integration.md +11 -0
- package/gsd-core/references/loop-hook-dispatch.md +13 -6
- package/gsd-core/references/planning-config.md +2 -0
- package/gsd-core/references/tdd.md +2 -2
- package/gsd-core/templates/DEBUG.md +16 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/audit-fix.md +2 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
- package/gsd-core/workflows/code-review.md +61 -7
- package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
- package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
- package/gsd-core/workflows/complete-milestone.md +37 -150
- package/gsd-core/workflows/diagnose-issues.md +29 -5
- package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
- package/gsd-core/workflows/discuss-phase-power.md +19 -2
- package/gsd-core/workflows/discuss-phase.md +6 -10
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
- package/gsd-core/workflows/execute-phase.md +9 -5
- package/gsd-core/workflows/health.md +2 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
- package/gsd-core/workflows/plan-phase.md +13 -13
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
- package/gsd-core/workflows/quick.md +1 -1
- package/gsd-core/workflows/review.md +16 -13
- package/gsd-core/workflows/section-manifest.json +5 -0
- package/gsd-core/workflows/settings.md +1 -0
- package/gsd-core/workflows/ship.md +3 -4
- package/gsd-core/workflows/spec-phase.md +3 -6
- package/gsd-core/workflows/verify-work.md +4 -3
- package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-path-export.js +95 -0
- package/hooks/dist/gsd-secret-read-guard.js +172 -39
- package/hooks/dist/gsd-statusline.js +35 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +28 -1
- package/hooks/dist/gsd-workflow-guard.js +1 -1
- package/hooks/dist/gsd-worktree-path-guard.js +28 -1
- package/hooks/dist/lib/isolation-sentinel.js +44 -0
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-agent-isolation-guard.js +11 -1
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-path-export.js +95 -0
- package/hooks/gsd-secret-read-guard.js +172 -39
- package/hooks/gsd-statusline.js +35 -2
- package/hooks/gsd-windsurf-pre-write.js +28 -1
- package/hooks/gsd-workflow-guard.js +1 -1
- package/hooks/gsd-worktree-path-guard.js +28 -1
- package/hooks/hooks.json +1 -0
- package/hooks/lib/isolation-sentinel.js +44 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +13 -6
- package/pi/gsd.cjs +1047 -10
- package/pi/package-agents/gsd-advisor-researcher.md +113 -0
- package/pi/package-agents/gsd-ai-researcher.md +111 -0
- package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
- package/pi/package-agents/gsd-code-fixer.md +769 -0
- package/pi/package-agents/gsd-code-reviewer.md +402 -0
- package/pi/package-agents/gsd-codebase-mapper.md +850 -0
- package/pi/package-agents/gsd-debug-session-manager.md +416 -0
- package/pi/package-agents/gsd-debugger.md +1274 -0
- package/pi/package-agents/gsd-doc-classifier.md +269 -0
- package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
- package/pi/package-agents/gsd-doc-verifier.md +212 -0
- package/pi/package-agents/gsd-doc-writer.md +626 -0
- package/pi/package-agents/gsd-dom-verifier.md +164 -0
- package/pi/package-agents/gsd-domain-researcher.md +144 -0
- package/pi/package-agents/gsd-eval-auditor.md +185 -0
- package/pi/package-agents/gsd-eval-planner.md +149 -0
- package/pi/package-agents/gsd-executor.md +894 -0
- package/pi/package-agents/gsd-framework-selector.md +159 -0
- package/pi/package-agents/gsd-integration-checker.md +476 -0
- package/pi/package-agents/gsd-intel-updater.md +338 -0
- package/pi/package-agents/gsd-mempalace-curator.md +49 -0
- package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
- package/pi/package-agents/gsd-pattern-mapper.md +340 -0
- package/pi/package-agents/gsd-phase-researcher.md +897 -0
- package/pi/package-agents/gsd-plan-checker.md +1077 -0
- package/pi/package-agents/gsd-planner.md +1003 -0
- package/pi/package-agents/gsd-project-researcher.md +611 -0
- package/pi/package-agents/gsd-research-synthesizer.md +259 -0
- package/pi/package-agents/gsd-roadmapper.md +779 -0
- package/pi/package-agents/gsd-security-auditor.md +171 -0
- package/pi/package-agents/gsd-ui-auditor.md +581 -0
- package/pi/package-agents/gsd-ui-checker.md +417 -0
- package/pi/package-agents/gsd-ui-researcher.md +469 -0
- package/pi/package-agents/gsd-user-profiler.md +174 -0
- package/pi/package-agents/gsd-verifier.md +963 -0
- package/pi/package-agents/manifest.json +39 -0
- package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
- package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
- package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
- package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
- package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
- package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
- package/pi/package-skills/gsd-capture/SKILL.md +68 -0
- package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
- package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
- package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
- package/pi/package-skills/gsd-config/SKILL.md +60 -0
- package/pi/package-skills/gsd-debug/SKILL.md +64 -0
- package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
- package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
- package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
- package/pi/package-skills/gsd-explore/SKILL.md +26 -0
- package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
- package/pi/package-skills/gsd-fast/SKILL.md +33 -0
- package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
- package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
- package/pi/package-skills/gsd-health/SKILL.md +36 -0
- package/pi/package-skills/gsd-help/SKILL.md +37 -0
- package/pi/package-skills/gsd-import/SKILL.md +46 -0
- package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
- package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
- package/pi/package-skills/gsd-manager/SKILL.md +46 -0
- package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
- package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
- package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
- package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
- package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
- package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
- package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
- package/pi/package-skills/gsd-next/SKILL.md +34 -0
- package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
- package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
- package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
- package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
- package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
- package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
- package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
- package/pi/package-skills/gsd-phase/SKILL.md +61 -0
- package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
- package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
- package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
- package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
- package/pi/package-skills/gsd-progress/SKILL.md +51 -0
- package/pi/package-skills/gsd-quick/SKILL.md +179 -0
- package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
- package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
- package/pi/package-skills/gsd-review/SKILL.md +50 -0
- package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
- package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
- package/pi/package-skills/gsd-settings/SKILL.md +30 -0
- package/pi/package-skills/gsd-ship/SKILL.md +27 -0
- package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
- package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
- package/pi/package-skills/gsd-spike/SKILL.md +53 -0
- package/pi/package-skills/gsd-stats/SKILL.md +22 -0
- package/pi/package-skills/gsd-surface/SKILL.md +177 -0
- package/pi/package-skills/gsd-thread/SKILL.md +29 -0
- package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
- package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
- package/pi/package-skills/gsd-undo/SKILL.md +38 -0
- package/pi/package-skills/gsd-update/SKILL.md +51 -0
- package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
- package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
- package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
- package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
- package/scripts/build-hooks.js +4 -0
- package/scripts/command-contract-helpers.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +20 -7
- package/scripts/gen-health-docs.cjs +13 -1
- package/scripts/gen-pi-package-skills.cjs +217 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +11 -1
- package/scripts/lib/platform-conformance-tier.generated.cjs +26 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
- package/scripts/lint-retired-runtime-name.cjs +5 -0
- package/scripts/npm-audit-baseline.cjs +41 -7
- package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
- package/scripts/select-docs-guards.cjs +1 -1
- package/scripts/self-test-report-policy.cjs +350 -0
- package/vscode/package.json +1 -1
- package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
- package/scripts/diagnose-validate-commit-phases.cjs +0 -348
- package/scripts/xtrace-phase-timing.cjs +0 -205
|
@@ -252,6 +252,39 @@ function runInstalledTreeAudit(cwd, opts = {}) {
|
|
|
252
252
|
return runNpmAuditWithRetry(cwd, ['audit', '--omit=dev', '--json'], opts);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* `-c safe.directory=<repoRoot>` for the baseline's git calls (#5659). On the gsd-test bench the
|
|
257
|
+
* checkout is owned by a different uid than the one running the tests, so git refuses every command
|
|
258
|
+
* ("detected dubious ownership") and the baseline silently resolved to nothing, which sent the
|
|
259
|
+
* integrity tests to their zero-tolerance fallback. git honors `safe.directory` from the command line
|
|
260
|
+
* only; scoping it to the audited repo root (never `*`, never global config) trusts exactly the
|
|
261
|
+
* checkout being audited. Same precedent as scripts/lint-compiled-artifact-sync.cjs.
|
|
262
|
+
*/
|
|
263
|
+
function safeDirectoryArgs(repoRoot) {
|
|
264
|
+
// git compares safe.directory against its forward-slash path, also on Windows.
|
|
265
|
+
return ['-c', `safe.directory=${String(repoRoot).replace(/\\/g, '/')}`];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One line saying why no baseline resolved (#5659), for the integrity gate's zero-tolerance message.
|
|
270
|
+
* Without it a git refusal (ownership, a missing git, a broken checkout) is indistinguishable from a
|
|
271
|
+
* checkout that simply has no `origin/next`, which is how the ownership problem hid. Only the first
|
|
272
|
+
* line git printed is kept.
|
|
273
|
+
*/
|
|
274
|
+
function explainNoBaseline(repoRoot, { execFileSyncImpl = execFileSync } = {}) {
|
|
275
|
+
try {
|
|
276
|
+
execFileSyncImpl('git', [...safeDirectoryArgs(repoRoot), 'rev-parse', '--git-dir'], {
|
|
277
|
+
cwd: repoRoot,
|
|
278
|
+
encoding: 'utf-8',
|
|
279
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const detail = String((error && (error.stderr || error.message)) || '').trim().split(/\r?\n/)[0];
|
|
283
|
+
return `git could not read ${repoRoot}${detail ? `: ${detail}` : ''}`;
|
|
284
|
+
}
|
|
285
|
+
return 'no baseline ref (origin/next, next) resolved, or it lacks package.json / package-lock.json';
|
|
286
|
+
}
|
|
287
|
+
|
|
255
288
|
/**
|
|
256
289
|
* Extracts package.json + package-lock.json (optionally under `subdir`,
|
|
257
290
|
* e.g. 'sdk') from `ref` at git object level into a fresh temp directory --
|
|
@@ -260,17 +293,17 @@ function runInstalledTreeAudit(cwd, opts = {}) {
|
|
|
260
293
|
* clone that never fetched it) or either file is absent at that ref --
|
|
261
294
|
* callers treat `null` as "no baseline available", not an error.
|
|
262
295
|
*/
|
|
263
|
-
function extractBaselineTree(ref, repoRoot, subdir = '') {
|
|
296
|
+
function extractBaselineTree(ref, repoRoot, subdir = '', { execFileSyncImpl = execFileSync } = {}) {
|
|
264
297
|
const rel = (name) => (subdir ? path.posix.join(subdir, name) : name);
|
|
265
298
|
let pkgJson;
|
|
266
299
|
let lockJson;
|
|
267
300
|
try {
|
|
268
|
-
pkgJson =
|
|
301
|
+
pkgJson = execFileSyncImpl('git', [...safeDirectoryArgs(repoRoot), 'show', `${ref}:${rel('package.json')}`], {
|
|
269
302
|
cwd: repoRoot,
|
|
270
303
|
encoding: 'utf-8',
|
|
271
304
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
272
305
|
});
|
|
273
|
-
lockJson =
|
|
306
|
+
lockJson = execFileSyncImpl('git', [...safeDirectoryArgs(repoRoot), 'show', `${ref}:${rel('package-lock.json')}`], {
|
|
274
307
|
cwd: repoRoot,
|
|
275
308
|
encoding: 'utf-8',
|
|
276
309
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -322,12 +355,12 @@ const NULL_SHA = '0000000000000000000000000000000000000000';
|
|
|
322
355
|
* Returns '' if none resolve -- callers fall back to strict zero-tolerance
|
|
323
356
|
* rather than silently skipping the gate.
|
|
324
357
|
*/
|
|
325
|
-
function resolveBaselineRef(repoRoot) {
|
|
358
|
+
function resolveBaselineRef(repoRoot, { execFileSyncImpl = execFileSync } = {}) {
|
|
326
359
|
if (process.env.AUDIT_BASELINE_REF) return process.env.AUDIT_BASELINE_REF;
|
|
327
360
|
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
|
328
361
|
if (process.env.GITHUB_EVENT_NAME === 'push') {
|
|
329
362
|
try {
|
|
330
|
-
const parent =
|
|
363
|
+
const parent = execFileSyncImpl('git', [...safeDirectoryArgs(repoRoot), 'rev-parse', '--verify', 'HEAD~1'], {
|
|
331
364
|
cwd: repoRoot,
|
|
332
365
|
encoding: 'utf-8',
|
|
333
366
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -338,7 +371,7 @@ function resolveBaselineRef(repoRoot) {
|
|
|
338
371
|
}
|
|
339
372
|
}
|
|
340
373
|
try {
|
|
341
|
-
|
|
374
|
+
execFileSyncImpl('git', [...safeDirectoryArgs(repoRoot), 'rev-parse', '--verify', 'origin/next'], {
|
|
342
375
|
cwd: repoRoot,
|
|
343
376
|
stdio: 'ignore',
|
|
344
377
|
});
|
|
@@ -347,7 +380,7 @@ function resolveBaselineRef(repoRoot) {
|
|
|
347
380
|
// fall through to a plain local branch
|
|
348
381
|
}
|
|
349
382
|
try {
|
|
350
|
-
|
|
383
|
+
execFileSyncImpl('git', [...safeDirectoryArgs(repoRoot), 'rev-parse', '--verify', 'next'], {
|
|
351
384
|
cwd: repoRoot,
|
|
352
385
|
stdio: 'ignore',
|
|
353
386
|
});
|
|
@@ -366,6 +399,7 @@ module.exports = {
|
|
|
366
399
|
runNpmAuditWithRetry,
|
|
367
400
|
extractBaselineTree,
|
|
368
401
|
resolveBaselineRef,
|
|
402
|
+
explainNoBaseline,
|
|
369
403
|
isTimeoutKill,
|
|
370
404
|
buildTimeoutKillError,
|
|
371
405
|
sleepSyncMs,
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "Reviewed or maintainer" policy (#5607).
|
|
6
|
+
*
|
|
7
|
+
* A pull request passes when EITHER
|
|
8
|
+
* 1. its author is listed in .github/MAINTAINERS (a maintainer's own PR needs
|
|
9
|
+
* no second reviewer), OR
|
|
10
|
+
* 2. someone other than the author, holding write/maintain/admin permission,
|
|
11
|
+
* has an APPROVED review on the CURRENT head sha as their latest standing
|
|
12
|
+
* review.
|
|
13
|
+
*
|
|
14
|
+
* Why this is a check and not a ruleset review count: a ruleset's
|
|
15
|
+
* required_approving_review_count is one number for every author, so it cannot
|
|
16
|
+
* say "required unless the author is a maintainer". The sole maintainer cannot
|
|
17
|
+
* approve their own PR, which is what pushed merges onto `--admin` (#4990).
|
|
18
|
+
*
|
|
19
|
+
* Everything that could wrongly COUNT an approval fails closed here: a stale
|
|
20
|
+
* sha, the author's own review, a reviewer without write access, a later
|
|
21
|
+
* dismissal or change request by the same reviewer, a truncated review list, an
|
|
22
|
+
* unreadable maintainer file. Wrongly FAILING only asks a maintainer for one
|
|
23
|
+
* more review.
|
|
24
|
+
*
|
|
25
|
+
* This module is pure. The workflow gathers reviews and roles with `gh api` and
|
|
26
|
+
* hands them in as files; the policy and the maintainer list are read from the
|
|
27
|
+
* BASE branch, never from the PR (.github/workflows/reviewed-or-maintainer.yml).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const { runMain } = require('./lib/cli-exit.cjs');
|
|
32
|
+
|
|
33
|
+
const REASON = Object.freeze({
|
|
34
|
+
OK_MAINTAINER_AUTHOR: 'ok_maintainer_author',
|
|
35
|
+
OK_APPROVED: 'ok_approved',
|
|
36
|
+
FAIL_NO_APPROVAL: 'fail_no_approval',
|
|
37
|
+
FAIL_STALE_APPROVAL_ONLY: 'fail_stale_approval_only',
|
|
38
|
+
FAIL_MAINTAINERS_UNREADABLE: 'fail_maintainers_unreadable',
|
|
39
|
+
FAIL_REVIEWS_INCOMPLETE: 'fail_reviews_incomplete',
|
|
40
|
+
FAIL_INPUT_INVALID: 'fail_input_invalid',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Permission levels allowed to give a qualifying approval. GitHub's
|
|
44
|
+
// `role_name` for collaborators: read, triage, write, maintain, admin.
|
|
45
|
+
const APPROVER_ROLES = new Set(['write', 'maintain', 'admin']);
|
|
46
|
+
|
|
47
|
+
// A GitHub login: alphanumerics and single hyphens, 1-39 chars, optional
|
|
48
|
+
// `[bot]` suffix. Anything else on a MAINTAINERS line is reported, not trusted.
|
|
49
|
+
const LOGIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\[bot\])?$/;
|
|
50
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
51
|
+
|
|
52
|
+
// Review states that change a reviewer's standing. COMMENTED and PENDING do
|
|
53
|
+
// not: a later comment does not withdraw an approval on GitHub either.
|
|
54
|
+
const STANDING_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {unknown} text - contents of .github/MAINTAINERS
|
|
58
|
+
* @returns {{ok: boolean, logins: Set<string>, invalid: string[]}}
|
|
59
|
+
*/
|
|
60
|
+
function parseMaintainers(text) {
|
|
61
|
+
const logins = new Set();
|
|
62
|
+
const invalid = [];
|
|
63
|
+
if (typeof text !== 'string') return { ok: false, logins, invalid };
|
|
64
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
65
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
66
|
+
if (line === '') continue;
|
|
67
|
+
if (LOGIN_RE.test(line)) logins.add(line.toLowerCase());
|
|
68
|
+
else invalid.push(line);
|
|
69
|
+
}
|
|
70
|
+
return { ok: logins.size > 0, logins, invalid };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isBot(entry) {
|
|
74
|
+
const user = entry && entry.user;
|
|
75
|
+
return Boolean(user && (user.type === 'Bot' || /\[bot\]$/i.test(String(user.login || ''))));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function lower(value) {
|
|
79
|
+
return typeof value === 'string' ? value.toLowerCase() : '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} input
|
|
84
|
+
* @param {string} input.authorLogin
|
|
85
|
+
* @param {Set<string>} input.maintainers - lowercase logins
|
|
86
|
+
* @param {Array<{user: {login: string}, state: string, commit_id: string}>} input.reviews - chronological
|
|
87
|
+
* @param {Record<string, string>} input.roles - lowercase login -> permission role name
|
|
88
|
+
* @param {string} input.headSha - 40 hex
|
|
89
|
+
* @param {boolean} input.reviewsComplete - false when the review list may be truncated
|
|
90
|
+
* @returns {{ok: boolean, reason: string}}
|
|
91
|
+
*/
|
|
92
|
+
function evaluateReviewed({ authorLogin, maintainers, reviews, roles, headSha, reviewsComplete }) {
|
|
93
|
+
if (typeof authorLogin !== 'string' || authorLogin === '') {
|
|
94
|
+
return { ok: false, reason: REASON.FAIL_INPUT_INVALID };
|
|
95
|
+
}
|
|
96
|
+
if (typeof headSha !== 'string' || !SHA_RE.test(headSha)) {
|
|
97
|
+
return { ok: false, reason: REASON.FAIL_INPUT_INVALID };
|
|
98
|
+
}
|
|
99
|
+
if (!(maintainers instanceof Set) || maintainers.size === 0) {
|
|
100
|
+
return { ok: false, reason: REASON.FAIL_MAINTAINERS_UNREADABLE };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const author = lower(authorLogin);
|
|
104
|
+
if (maintainers.has(author)) return { ok: true, reason: REASON.OK_MAINTAINER_AUTHOR };
|
|
105
|
+
|
|
106
|
+
if (!Array.isArray(reviews)) return { ok: false, reason: REASON.FAIL_INPUT_INVALID };
|
|
107
|
+
if (reviewsComplete !== true) return { ok: false, reason: REASON.FAIL_REVIEWS_INCOMPLETE };
|
|
108
|
+
|
|
109
|
+
// Latest standing review per reviewer (input is chronological).
|
|
110
|
+
const standing = new Map();
|
|
111
|
+
for (const entry of reviews) {
|
|
112
|
+
const login = lower(entry && entry.user && entry.user.login);
|
|
113
|
+
if (login === '' || !STANDING_STATES.has(entry.state)) continue;
|
|
114
|
+
standing.set(login, entry);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let sawStaleApproval = false;
|
|
118
|
+
for (const [login, entry] of standing) {
|
|
119
|
+
if (login === author) continue;
|
|
120
|
+
if (entry.state !== 'APPROVED') continue;
|
|
121
|
+
// An app or bot (for example github-actions[bot] when Actions may approve
|
|
122
|
+
// PRs) is not a human review, whatever permission it holds.
|
|
123
|
+
if (isBot(entry)) continue;
|
|
124
|
+
const role = roles && typeof roles === 'object' ? roles[login] : undefined;
|
|
125
|
+
if (!APPROVER_ROLES.has(role)) continue;
|
|
126
|
+
if (lower(entry.commit_id) === lower(headSha)) return { ok: true, reason: REASON.OK_APPROVED };
|
|
127
|
+
sawStaleApproval = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: sawStaleApproval ? REASON.FAIL_STALE_APPROVAL_ONLY : REASON.FAIL_NO_APPROVAL,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readJson(file) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function main() {
|
|
145
|
+
const maintainersText = (() => {
|
|
146
|
+
try {
|
|
147
|
+
return fs.readFileSync(process.env.MAINTAINERS_FILE || '.github/MAINTAINERS', 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
const parsed = parseMaintainers(maintainersText);
|
|
153
|
+
for (const line of parsed.invalid) {
|
|
154
|
+
process.stderr.write(`::warning::MAINTAINERS: ignored malformed line: ${JSON.stringify(line)}\n`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const reviews = process.env.REVIEWS_FILE ? readJson(process.env.REVIEWS_FILE) : undefined;
|
|
158
|
+
const roles = process.env.ROLES_FILE ? readJson(process.env.ROLES_FILE) : undefined;
|
|
159
|
+
|
|
160
|
+
const result = evaluateReviewed({
|
|
161
|
+
authorLogin: process.env.PR_AUTHOR_LOGIN || '',
|
|
162
|
+
maintainers: parsed.ok ? parsed.logins : null,
|
|
163
|
+
reviews,
|
|
164
|
+
roles: roles && typeof roles === 'object' ? roles : {},
|
|
165
|
+
headSha: process.env.HEAD_SHA || '',
|
|
166
|
+
// The workflow's `gh api --paginate` either returns every page or exits
|
|
167
|
+
// non-zero before this runs; it states completeness explicitly.
|
|
168
|
+
reviewsComplete: process.env.REVIEWS_COMPLETE === 'true',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
172
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
173
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `ok=${result.ok ? 'true' : 'false'}\n`);
|
|
174
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `reason=${result.reason}\n`);
|
|
175
|
+
}
|
|
176
|
+
return result.ok ? 0 : 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (require.main === module) runMain(main);
|
|
180
|
+
|
|
181
|
+
module.exports = { REASON, APPROVER_ROLES, parseMaintainers, evaluateReviewed };
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* @param {string} changedPath - a single changed docs/ path (e.g. 'docs/
|
|
25
|
+
* @param {string} changedPath - a single changed docs/ path (e.g. 'docs/reference/agents.md').
|
|
26
26
|
* @param {string} pattern - one entry from a registry value array.
|
|
27
27
|
* @returns {boolean}
|
|
28
28
|
*/
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Self-test report policy (#5612).
|
|
6
|
+
*
|
|
7
|
+
* A fix PR from someone who is not a listed maintainer must carry a
|
|
8
|
+
* `## Self-test report` section holding a fenced `json` block that records what
|
|
9
|
+
* the submitter ran: a failing-first test (fails on the base, passes on the
|
|
10
|
+
* head), the commands, the environment, and a passing full suite, bound to the
|
|
11
|
+
* PR's CURRENT head commit.
|
|
12
|
+
*
|
|
13
|
+
* TRUST MODEL. This validates shape and sha binding, not truth. A local run
|
|
14
|
+
* cannot be attested; CI on the merge-group commit stays authoritative. The
|
|
15
|
+
* report is a head start for the reviewer and a nudge on effort, nothing more.
|
|
16
|
+
* Every result here is advisory until a maintainer chooses to require it.
|
|
17
|
+
*
|
|
18
|
+
* Pure module: the workflow hands in the PR body, head ref, title, author, head
|
|
19
|
+
* sha and the maintainer list (read from the BASE sha, never the PR).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const { runMain } = require('./lib/cli-exit.cjs');
|
|
24
|
+
const { parseMaintainers } = require('./reviewed-or-maintainer-policy.cjs');
|
|
25
|
+
|
|
26
|
+
const REASON = Object.freeze({
|
|
27
|
+
OK_NOT_A_FIX: 'ok_not_a_fix',
|
|
28
|
+
OK_MAINTAINER_AUTHOR: 'ok_maintainer_author',
|
|
29
|
+
OK_BOT_AUTHOR: 'ok_bot_author',
|
|
30
|
+
OK_REPORT: 'ok_report',
|
|
31
|
+
FAIL_NO_REPORT: 'fail_no_report',
|
|
32
|
+
FAIL_MALFORMED_JSON: 'fail_malformed_json',
|
|
33
|
+
FAIL_SCHEMA: 'fail_schema',
|
|
34
|
+
FAIL_STALE_SHA: 'fail_stale_sha',
|
|
35
|
+
FAIL_NOT_FAILING_FIRST: 'fail_not_failing_first',
|
|
36
|
+
FAIL_SUITE_NOT_PASSED: 'fail_suite_not_passed',
|
|
37
|
+
FAIL_MAINTAINERS_UNREADABLE: 'fail_maintainers_unreadable',
|
|
38
|
+
FAIL_INPUT_INVALID: 'fail_input_invalid',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const SCHEMA_VERSION = 1;
|
|
42
|
+
const TOOLS = new Set(['gsd-test', 'npm-test', 'manual']);
|
|
43
|
+
const SUITE_RESULTS = new Set(['passed', 'failed', 'skipped']);
|
|
44
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
45
|
+
const PLACEHOLDER_RE = /^<[^<>]*>$/;
|
|
46
|
+
const EXCERPT_MAX = 2000;
|
|
47
|
+
const SECTION_TITLE = 'self-test report';
|
|
48
|
+
|
|
49
|
+
/** A PR is a "fix PR" when its head ref is fix/… or its title is a fix. */
|
|
50
|
+
function isFixPr({ headRef, title }) {
|
|
51
|
+
if (typeof headRef === 'string' && headRef.startsWith('fix/')) return true;
|
|
52
|
+
return typeof title === 'string' && /^fix(\([^)]*\))?!?:/i.test(title.trim());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isBotLogin(login) {
|
|
56
|
+
return typeof login === 'string' && /\[bot\]$/i.test(login);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find the first fenced ```json block inside the `## Self-test report` section.
|
|
61
|
+
*
|
|
62
|
+
* - Fence-aware: a `#` line inside a fence is not a heading. A fence may carry
|
|
63
|
+
* trailing info after the language (```json title="x"); only its first word
|
|
64
|
+
* names the language. A closing fence has no info and is at least as long as
|
|
65
|
+
* the opener, with the same character.
|
|
66
|
+
* - HTML comments are skipped outside fences, so a block hidden in a comment
|
|
67
|
+
* (invisible in the rendered PR) is not the report.
|
|
68
|
+
* - The section ends at the next heading of the same or a higher level.
|
|
69
|
+
* - The first json block wins.
|
|
70
|
+
*
|
|
71
|
+
* @returns {{found: boolean, json: string|null}}
|
|
72
|
+
*/
|
|
73
|
+
// Lines longer than this are not fence or heading syntax. Capping what the
|
|
74
|
+
// regexes see keeps a crafted line of whitespace from costing more than a bounded
|
|
75
|
+
// amount of work (the body is attacker-controlled); captured JSON keeps its full text.
|
|
76
|
+
const SYNTAX_LINE_MAX = 1000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ATX heading → {level, text}, parsed without a backtracking regex: a body line
|
|
80
|
+
* is attacker input and the obvious `^(#+)\s+(.*?)\s*#*\s*$` is cubic on a long
|
|
81
|
+
* run of spaces. A trailing run of `#` is a closing sequence only when preceded
|
|
82
|
+
* by whitespace (or the text is only hashes).
|
|
83
|
+
*/
|
|
84
|
+
function parseHeading(line) {
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
let level = 0;
|
|
87
|
+
while (level < trimmed.length && trimmed[level] === '#') level += 1;
|
|
88
|
+
if (level < 1 || level > 6) return null;
|
|
89
|
+
if (level >= trimmed.length || !/\s/.test(trimmed[level])) return null;
|
|
90
|
+
let text = trimmed.slice(level).trim();
|
|
91
|
+
let end = text.length;
|
|
92
|
+
while (end > 0 && text[end - 1] === '#') end -= 1;
|
|
93
|
+
if (end < text.length && (end === 0 || /\s/.test(text[end - 1]))) text = text.slice(0, end).trimEnd();
|
|
94
|
+
return { level, text: text.replace(/\*\*/g, '').trim().toLowerCase() };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractReport(body) {
|
|
98
|
+
const lines = String(body || '').split(/\r?\n/);
|
|
99
|
+
let inFence = false;
|
|
100
|
+
let fenceChar = '';
|
|
101
|
+
let fenceLen = 0;
|
|
102
|
+
let inComment = false;
|
|
103
|
+
let sectionLevel = 0;
|
|
104
|
+
let capturing = false;
|
|
105
|
+
let captured = [];
|
|
106
|
+
|
|
107
|
+
for (const full of lines) {
|
|
108
|
+
const line = full.length > SYNTAX_LINE_MAX ? full.slice(0, SYNTAX_LINE_MAX) : full;
|
|
109
|
+
if (!inFence) {
|
|
110
|
+
if (inComment) {
|
|
111
|
+
if (line.includes('-->')) inComment = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const commentAt = line.indexOf('<!--');
|
|
115
|
+
if (commentAt !== -1 && line.indexOf('-->', commentAt + 4) === -1) {
|
|
116
|
+
inComment = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!inFence) {
|
|
122
|
+
const open = /^\s*(`{3,}|~{3,})\s*([^\s`]*)(?:\s.*)?$/.exec(line);
|
|
123
|
+
if (open) {
|
|
124
|
+
if (sectionLevel > 0 && !capturing && open[2].toLowerCase() === 'json') {
|
|
125
|
+
capturing = true;
|
|
126
|
+
captured = [];
|
|
127
|
+
}
|
|
128
|
+
inFence = true;
|
|
129
|
+
fenceChar = open[1][0];
|
|
130
|
+
fenceLen = open[1].length;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(line);
|
|
135
|
+
if (close && close[1][0] === fenceChar && close[1].length >= fenceLen) {
|
|
136
|
+
inFence = false;
|
|
137
|
+
if (capturing) return { found: true, json: captured.join('\n') };
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (capturing) captured.push(full);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const heading = parseHeading(line);
|
|
145
|
+
if (heading) {
|
|
146
|
+
if (sectionLevel === 0) {
|
|
147
|
+
if (heading.text === SECTION_TITLE) sectionLevel = heading.level;
|
|
148
|
+
} else if (heading.level <= sectionLevel) {
|
|
149
|
+
return { found: false, json: null };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { found: false, json: null };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isNonEmptyString(value) {
|
|
157
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isCount(value) {
|
|
161
|
+
return Number.isInteger(value) && value >= 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Names of report fields whose string value is still an unfilled `<…>` placeholder. */
|
|
165
|
+
function placeholderFields(report) {
|
|
166
|
+
const found = [];
|
|
167
|
+
const check = (name, value) => {
|
|
168
|
+
if (typeof value === 'string' && PLACEHOLDER_RE.test(value.trim())) found.push(name);
|
|
169
|
+
};
|
|
170
|
+
check('head_sha', report.head_sha);
|
|
171
|
+
check('output_excerpt', report.output_excerpt);
|
|
172
|
+
if (report.failing_first && typeof report.failing_first === 'object') {
|
|
173
|
+
check('failing_first.test', report.failing_first.test);
|
|
174
|
+
check('failing_first.not_applicable_reason', report.failing_first.not_applicable_reason);
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(report.commands)) report.commands.forEach((c) => check('commands', c));
|
|
177
|
+
if (report.full_suite && typeof report.full_suite === 'object') check('full_suite.command', report.full_suite.command);
|
|
178
|
+
if (report.environment && typeof report.environment === 'object') {
|
|
179
|
+
check('environment.os', report.environment.os);
|
|
180
|
+
check('environment.node', report.environment.node);
|
|
181
|
+
}
|
|
182
|
+
return [...new Set(found)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {unknown} report - parsed JSON
|
|
187
|
+
* @param {string} headSha
|
|
188
|
+
* @returns {{ok: boolean, reason: string, problems: string[]}}
|
|
189
|
+
*/
|
|
190
|
+
function validateReport(report, headSha) {
|
|
191
|
+
const problems = [];
|
|
192
|
+
const fail = (reason) => ({ ok: false, reason, problems });
|
|
193
|
+
|
|
194
|
+
if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
|
195
|
+
problems.push('the report must be a JSON object');
|
|
196
|
+
return fail(REASON.FAIL_SCHEMA);
|
|
197
|
+
}
|
|
198
|
+
if (report.schema !== SCHEMA_VERSION) problems.push(`schema must be ${SCHEMA_VERSION}`);
|
|
199
|
+
if (typeof report.head_sha !== 'string' || !SHA_RE.test(report.head_sha)) {
|
|
200
|
+
problems.push('head_sha must be the 40-hex sha of your final commit');
|
|
201
|
+
}
|
|
202
|
+
if (!TOOLS.has(report.tool)) problems.push(`tool must be one of ${[...TOOLS].join(', ')}`);
|
|
203
|
+
|
|
204
|
+
const ff = report.failing_first;
|
|
205
|
+
if (!ff || typeof ff !== 'object' || Array.isArray(ff)) {
|
|
206
|
+
problems.push('failing_first must be an object');
|
|
207
|
+
} else if (ff.not_applicable_reason !== undefined) {
|
|
208
|
+
if (!isNonEmptyString(ff.not_applicable_reason)) {
|
|
209
|
+
problems.push('failing_first.not_applicable_reason must be a non-empty explanation');
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
if (!isNonEmptyString(ff.test)) problems.push('failing_first.test must name the test file');
|
|
213
|
+
if (typeof ff.fails_on_base !== 'boolean') problems.push('failing_first.fails_on_base must be true or false');
|
|
214
|
+
if (typeof ff.passes_on_head !== 'boolean') problems.push('failing_first.passes_on_head must be true or false');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!Array.isArray(report.commands) || report.commands.length === 0 || !report.commands.every(isNonEmptyString)) {
|
|
218
|
+
problems.push('commands must be a non-empty list of the commands you ran');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const suite = report.full_suite;
|
|
222
|
+
if (!suite || typeof suite !== 'object' || Array.isArray(suite)) {
|
|
223
|
+
problems.push('full_suite must be an object');
|
|
224
|
+
} else {
|
|
225
|
+
if (!isNonEmptyString(suite.command)) problems.push('full_suite.command must name the command');
|
|
226
|
+
if (!SUITE_RESULTS.has(suite.result)) problems.push(`full_suite.result must be one of ${[...SUITE_RESULTS].join(', ')}`);
|
|
227
|
+
if (!isCount(suite.passed)) problems.push('full_suite.passed must be a non-negative integer');
|
|
228
|
+
if (!isCount(suite.failed)) problems.push('full_suite.failed must be a non-negative integer');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const env = report.environment;
|
|
232
|
+
if (!env || typeof env !== 'object' || Array.isArray(env) || !isNonEmptyString(env.os) || !isNonEmptyString(env.node)) {
|
|
233
|
+
problems.push('environment must give os and node');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!isNonEmptyString(report.output_excerpt) || report.output_excerpt.length > EXCERPT_MAX) {
|
|
237
|
+
problems.push(`output_excerpt must be a non-empty excerpt of at most ${EXCERPT_MAX} characters`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A value still in `<…>` is the template, not something the submitter filled in.
|
|
241
|
+
for (const name of placeholderFields(report)) problems.push(`${name} is still the template placeholder; fill it in`);
|
|
242
|
+
|
|
243
|
+
if (problems.length > 0) return fail(REASON.FAIL_SCHEMA);
|
|
244
|
+
|
|
245
|
+
// Structurally sound. Now the substantive checks, most informative first.
|
|
246
|
+
if (report.head_sha.toLowerCase() !== String(headSha).toLowerCase()) {
|
|
247
|
+
problems.push(`head_sha ${report.head_sha} is not the PR's current head ${headSha}; update the report after your last push`);
|
|
248
|
+
return fail(REASON.FAIL_STALE_SHA);
|
|
249
|
+
}
|
|
250
|
+
if (ff.not_applicable_reason === undefined && !(ff.fails_on_base === true && ff.passes_on_head === true)) {
|
|
251
|
+
problems.push('the failing-first test must fail on the base and pass on your head');
|
|
252
|
+
return fail(REASON.FAIL_NOT_FAILING_FIRST);
|
|
253
|
+
}
|
|
254
|
+
if (suite.result !== 'passed' || suite.failed !== 0 || suite.passed < 1) {
|
|
255
|
+
problems.push('the full suite must have passed, with at least one passing test and no failures');
|
|
256
|
+
return fail(REASON.FAIL_SUITE_NOT_PASSED);
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, reason: REASON.OK_REPORT, problems };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {object} input
|
|
263
|
+
* @param {string} input.body
|
|
264
|
+
* @param {string} input.headRef
|
|
265
|
+
* @param {string} input.title
|
|
266
|
+
* @param {string} input.authorLogin
|
|
267
|
+
* @param {string} input.headSha - 40 hex, read live
|
|
268
|
+
* @param {Set<string>|null} input.maintainers - lowercase logins
|
|
269
|
+
* @returns {{ok: boolean, reason: string, problems: string[]}}
|
|
270
|
+
*/
|
|
271
|
+
function evaluateSelfTestReport({ body, headRef, title, authorLogin, headSha, maintainers }) {
|
|
272
|
+
const none = [];
|
|
273
|
+
if (typeof authorLogin !== 'string' || authorLogin === '' || typeof headSha !== 'string' || !SHA_RE.test(headSha)) {
|
|
274
|
+
return { ok: false, reason: REASON.FAIL_INPUT_INVALID, problems: none };
|
|
275
|
+
}
|
|
276
|
+
if (!isFixPr({ headRef, title })) return { ok: true, reason: REASON.OK_NOT_A_FIX, problems: none };
|
|
277
|
+
if (isBotLogin(authorLogin)) return { ok: true, reason: REASON.OK_BOT_AUTHOR, problems: none };
|
|
278
|
+
if (!(maintainers instanceof Set) || maintainers.size === 0) {
|
|
279
|
+
return { ok: false, reason: REASON.FAIL_MAINTAINERS_UNREADABLE, problems: none };
|
|
280
|
+
}
|
|
281
|
+
if (maintainers.has(authorLogin.toLowerCase())) {
|
|
282
|
+
return { ok: true, reason: REASON.OK_MAINTAINER_AUTHOR, problems: none };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const extracted = extractReport(body);
|
|
286
|
+
if (!extracted.found) {
|
|
287
|
+
return { ok: false, reason: REASON.FAIL_NO_REPORT, problems: ['no ```json block under a "## Self-test report" heading'] };
|
|
288
|
+
}
|
|
289
|
+
let parsed;
|
|
290
|
+
try {
|
|
291
|
+
parsed = JSON.parse(extracted.json);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { ok: false, reason: REASON.FAIL_MALFORMED_JSON, problems: [`the report is not valid JSON: ${error.message}`] };
|
|
294
|
+
}
|
|
295
|
+
return validateReport(parsed, headSha);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A value from the environment, or from the file named by `<NAME>_FILE` (live reads are too big for env). */
|
|
299
|
+
function fromEnvOrFile(name) {
|
|
300
|
+
const file = process.env[`${name}_FILE`];
|
|
301
|
+
if (file) {
|
|
302
|
+
try {
|
|
303
|
+
return fs.readFileSync(file, 'utf8');
|
|
304
|
+
} catch {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return process.env[name] || '';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function main() {
|
|
312
|
+
let maintainersText;
|
|
313
|
+
try {
|
|
314
|
+
maintainersText = fs.readFileSync(process.env.MAINTAINERS_FILE || '.github/MAINTAINERS', 'utf8');
|
|
315
|
+
} catch {
|
|
316
|
+
maintainersText = undefined;
|
|
317
|
+
}
|
|
318
|
+
const parsed = parseMaintainers(maintainersText);
|
|
319
|
+
|
|
320
|
+
const body = fromEnvOrFile('PR_BODY');
|
|
321
|
+
const title = fromEnvOrFile('PR_TITLE');
|
|
322
|
+
|
|
323
|
+
const result = body === undefined || title === undefined
|
|
324
|
+
? { ok: false, reason: REASON.FAIL_INPUT_INVALID, problems: [] }
|
|
325
|
+
: evaluateSelfTestReport({
|
|
326
|
+
body,
|
|
327
|
+
headRef: process.env.HEAD_REF || '',
|
|
328
|
+
title,
|
|
329
|
+
authorLogin: process.env.PR_AUTHOR_LOGIN || '',
|
|
330
|
+
headSha: process.env.HEAD_SHA || '',
|
|
331
|
+
maintainers: parsed.ok ? parsed.logins : null,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
335
|
+
for (const problem of result.problems) {
|
|
336
|
+
// The annotation text is the validator's own wording plus, for a JSON syntax
|
|
337
|
+
// error, the parser's message (which can echo attacker text): strip newlines
|
|
338
|
+
// so no extra workflow command can start a line.
|
|
339
|
+
process.stderr.write(`::error::Self-test report: ${problem.replace(/[\r\n]+/g, ' ').slice(0, 300)}\n`);
|
|
340
|
+
}
|
|
341
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
342
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `ok=${result.ok ? 'true' : 'false'}\n`);
|
|
343
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `reason=${result.reason}\n`);
|
|
344
|
+
}
|
|
345
|
+
return result.ok ? 0 : 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (require.main === module) runMain(main);
|
|
349
|
+
|
|
350
|
+
module.exports = { REASON, SCHEMA_VERSION, EXCERPT_MAX, isFixPr, extractReport, validateReport, evaluateSelfTestReport };
|
package/vscode/package.json
CHANGED