@event4u/agent-config 9.16.0 → 9.18.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/AGENTS.md +1 -1
- package/CHANGELOG.md +80 -130
- package/README.md +1 -1
- package/dist/agent-src/commands/agent-handoff.md +100 -48
- package/dist/agent-src/commands/challenge-me/vision.md +5 -0
- package/dist/agent-src/commands/commit.md +1 -1
- package/dist/agent-src/commands/condense.md +19 -64
- package/dist/agent-src/commands/evals/agent-handoff.json +13 -1
- package/dist/agent-src/commands/evals/rule-compliance-audit.json +46 -0
- package/dist/agent-src/commands/feature/plan.md +20 -0
- package/dist/agent-src/commands/feature/roadmap.md +19 -0
- package/dist/agent-src/commands/memory/promote.md +14 -1
- package/dist/agent-src/commands/mode.md +9 -5
- package/dist/agent-src/commands/optimize/deep.md +72 -11
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/roadmap/create.md +29 -0
- package/dist/agent-src/commands/rule-compliance-audit.md +28 -5
- package/dist/agent-src/contexts/authority/commit-mechanics.md +5 -5
- package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
- package/dist/agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +1 -1
- package/dist/agent-src/contexts/communication/rules-auto/think-before-action-mechanics.md +6 -1
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +4 -1
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +27 -1
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +133 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +5 -2
- package/dist/agent-src/profiles/agency.yml +3 -3
- package/dist/agent-src/profiles/content_creator.yml +2 -2
- package/dist/agent-src/profiles/developer.yml +2 -2
- package/dist/agent-src/profiles/finance.yml +1 -1
- package/dist/agent-src/profiles/founder.yml +2 -2
- package/dist/agent-src/profiles/ops.yml +2 -2
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/architecture.md +4 -2
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/brand-consistency.md +10 -62
- package/dist/agent-src/rules/brand-source-of-truth.md +62 -11
- package/dist/agent-src/rules/broken-access-control.md +4 -0
- package/dist/agent-src/rules/cli-output-handling.md +5 -0
- package/dist/agent-src/rules/code-comment-discipline.md +3 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/context-hygiene.md +3 -2
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/design-fidelity.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +4 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +5 -2
- package/dist/agent-src/rules/domain-safety-pii.md +1 -1
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +9 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +4 -0
- package/dist/agent-src/rules/improve-before-implement.md +4 -0
- package/dist/agent-src/rules/laravel-routing.md +4 -0
- package/dist/agent-src/rules/legal-safety-floor.md +3 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +5 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +9 -2
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +3 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +5 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +3 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +9 -2
- package/dist/agent-src/rules/senior-engineering-discipline.md +6 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +4 -0
- package/dist/agent-src/rules/source-discovery-gate.md +3 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +5 -0
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/rules/ui-audit-gate.md +2 -0
- package/dist/agent-src/rules/user-interaction.md +3 -4
- package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -1
- package/dist/agent-src/skills/copilot-agents-optimization/SKILL.md +4 -4
- package/dist/agent-src/skills/copilot-config/SKILL.md +1 -1
- package/dist/agent-src/skills/deep-reading-analyst/SKILL.md +7 -4
- package/dist/agent-src/skills/description-assist/SKILL.md +1 -1
- package/dist/agent-src/skills/design-intelligence/SKILL.md +6 -180
- package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +100 -0
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +91 -0
- package/dist/agent-src/skills/design-review/SKILL.md +4 -141
- package/dist/agent-src/skills/design-review/references/review-communication.md +92 -0
- package/dist/agent-src/skills/design-review/references/verification-automation.md +61 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +8 -27
- package/dist/agent-src/skills/existing-ui-audit/references/anti-slop-cross-reference.md +31 -0
- package/dist/agent-src/skills/fe-design/SKILL.md +4 -289
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +227 -0
- package/dist/agent-src/skills/fe-design/references/design-read-and-memory.md +75 -0
- package/dist/agent-src/skills/market-entry-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-writing/SKILL.md +2 -2
- package/dist/agent-src/skills/readme-reviewer/SKILL.md +2 -1
- package/dist/agent-src/skills/roadmap-management/SKILL.md +10 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +12 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +4 -2
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +2 -2
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +3 -1
- package/dist/agent-src/skills/token-optimizer/SKILL.md +2 -2
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +6 -3
- package/dist/agent-src/templates/agent-settings.md +13 -15
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +6 -2
- package/dist/agent-src/templates/persona.md +2 -2
- package/dist/agent-src/templates/roadmaps.md +13 -1
- package/dist/agent-src/templates/rule.md +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
- package/dist/agent-src/templates/skill.md +1 -1
- package/dist/cli/registry.js +3 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-AIIUU73N.js +2040 -0
- package/dist/cli-delegate/{chunk-ZXXJLL5K.js → chunk-BSLUHV2F.js} +127 -1487
- package/dist/cli-delegate/{chunk-H574KOUH.js → chunk-ELMFOQPW.js} +35 -1
- package/dist/cli-delegate/chunk-LF2KKJ4Q.js +183 -0
- package/dist/cli-delegate/chunk-VSYT7OHO.js +1382 -0
- package/dist/cli-delegate/chunk-XGA2NTU4.js +3727 -0
- package/dist/cli-delegate/cmd_conformance.js +4 -3
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_explain.js +36 -36
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +1169 -0
- package/dist/cli-delegate/cmd_memory_get.js +35 -3754
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +249 -0
- package/dist/cli-delegate/cmd_route_explain.js +285 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +3 -3
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +84 -84
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +602 -507
- package/dist/install/install.mjs +34 -0
- package/dist/mcp/awesome-mcp-servers.row.md +1 -1
- package/dist/mcp/mcp-cloudflare-catalogue.json +2 -2
- package/dist/mcp/registry-manifest.json +4 -4
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +25 -3
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +39 -6
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/server/schemas/settings.js +25 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/guidelines/abstraction-thresholds.md +53 -0
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +1 -1
- package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +7 -0
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +2 -2
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +5 -3
- package/docs/guidelines/agent-infra/size-and-scope.md +8 -2
- package/docs/guidelines/component-oriented-and-oop-development.md +11 -6
- package/docs/guidelines/docs/readme-size-and-splitting.md +5 -2
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/_fix_lane.ts +52 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +8 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +56 -4
- package/src/config/agent-settings.template.yml +68 -0
- package/src/config/evaluator-budgets.json +8 -11
- package/src/config/gate-coverage.yml +70 -0
- package/src/scripts/_cli/cmd_explain.ts +41 -36
- package/src/scripts/_cli/cmd_handoff.ts +405 -0
- package/src/scripts/_cli/cmd_route_audit.ts +290 -0
- package/src/scripts/_cli/cmd_route_explain.ts +269 -0
- package/src/scripts/_cli/handoff_generate.ts +453 -0
- package/src/scripts/_cli/handoff_sessions.ts +435 -0
- package/src/scripts/_dispatch.bash +32 -0
- package/src/scripts/_lib/agent_src.ts +36 -6
- package/src/scripts/_lib/git_env.ts +40 -0
- package/src/scripts/_lib/md_table.ts +27 -0
- package/src/scripts/_lib/planning_settings.ts +43 -0
- package/src/scripts/_lib/release_material.ts +156 -0
- package/src/scripts/_lib/router_match.ts +301 -0
- package/src/scripts/annotate_r1_outcomes.ts +258 -0
- package/src/scripts/check_completion_review.ts +1140 -0
- package/src/scripts/check_condensation.ts +2 -1
- package/src/scripts/check_finding_dispositions.ts +278 -0
- package/src/scripts/check_pack_size.ts +4 -3
- package/src/scripts/check_references.ts +1 -0
- package/src/scripts/check_release_highlights.ts +300 -0
- package/src/scripts/check_release_pr_shape.ts +4 -0
- package/src/scripts/check_release_surface_equality.ts +132 -0
- package/src/scripts/check_reply_consistency.ts +187 -138
- package/src/scripts/compile_router.ts +20 -3
- package/src/scripts/dispatch_r2_reviewer.ts +968 -0
- package/src/scripts/eval_ui_triviality.ts +156 -0
- package/src/scripts/handoff_context_hook.ts +148 -0
- package/src/scripts/hook_manifest.yaml +17 -7
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/lint_abstraction_thresholds.ts +327 -0
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_plan_risk_register.ts +931 -0
- package/src/scripts/lint_profile_personas.ts +153 -0
- package/src/scripts/lint_token_budget_discipline.ts +148 -0
- package/src/scripts/lint_trigger_collisions.ts +257 -0
- package/src/scripts/release.ts +156 -32
- package/src/scripts/release_drill.ts +491 -0
- package/src/scripts/router_telemetry.ts +16 -290
- package/src/scripts/schemas/command.schema.json +33 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/scripts/self_review_gate.ts +41 -3
- package/src/scripts/skill_linter.ts +3 -1
- package/src/scripts/smoke/router.sh +1 -1
- package/src/scripts/update_counts.ts +60 -12
- package/src/server/schemas/settings.ts +41 -0
- package/dist/cli-delegate/{chunk-XX6TOPD7.js → chunk-WR6GTRHJ.js} +3 -3
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Gate R2 — completion-review validator (deterministic).
|
|
4
|
+
*
|
|
5
|
+
* Implements exactly the findings-artifact grammar in
|
|
6
|
+
* `docs/contracts/plan-review-gates.md` §2 (header marker, findings table,
|
|
7
|
+
* honest-null grammar, skip-declaration grammar, findings-before-fixes
|
|
8
|
+
* ancestry) and the exit-code contract in §6. A divergence from that file is
|
|
9
|
+
* a validator bug, never a contract reinterpretation.
|
|
10
|
+
*
|
|
11
|
+
* What it checks, given the current branch state (`--repo`, `--base`):
|
|
12
|
+
* - a completion-review artifact (`*.findings.md` under `--artifact-dir`)
|
|
13
|
+
* exists for the current REVIEW-SCOPE HASH, OR a valid skip declaration
|
|
14
|
+
* covers it;
|
|
15
|
+
* - the artifact carries a §5 context manifest (a relevant artifact without
|
|
16
|
+
* one is unverifiable → `missing-manifest`);
|
|
17
|
+
* - every finding row is terminal (`fixed` / `accepted-risk` / `deferred`)
|
|
18
|
+
* with the required Reason/Ref content;
|
|
19
|
+
* - every table-shaped row parses into the six §2.2 cells (`malformed-row`) —
|
|
20
|
+
* a short row is never silently dropped, or an `open` finding would pass;
|
|
21
|
+
* - severity rows are sorted descending (critical > high > medium > low);
|
|
22
|
+
* - findings-before-fixes ancestry: the commit that FIRST added the
|
|
23
|
+
* artifact is an ancestor of every referenced fix commit (§2.5 — the
|
|
24
|
+
* first-add commit is what counts, so backdating is detected too).
|
|
25
|
+
*
|
|
26
|
+
* The review binds to the review-SCOPE HASH, never to a commit sha. That
|
|
27
|
+
* definition is owned by `dispatch_r2_reviewer.ts` and imported here so the
|
|
28
|
+
* dispatcher and the validator can never diverge. Manifest hash
|
|
29
|
+
* re-derivation stays a separate step (`dispatch_r2_reviewer --verify`).
|
|
30
|
+
*
|
|
31
|
+
* Settings escape hatch: `planning.completion_review: false` in the repo's
|
|
32
|
+
* `.agent-settings.yml` skips the gate (note + `scanned: 0` + exit 0), exactly
|
|
33
|
+
* as `planning.risk_review: false` skips Gate R1.
|
|
34
|
+
*
|
|
35
|
+
* Exit codes (contract §6): 0 = pass, 1 = policy violation (including a DEAD
|
|
36
|
+
* SCAN SCOPE — a gate that read nothing has not passed), 2 = internal error
|
|
37
|
+
* (missing base ref, git failure — the CALLER applies degraded advisory
|
|
38
|
+
* mode). With `--advisory` (Stage-A window, §2) violations are reported as
|
|
39
|
+
* warnings and the exit is ALWAYS 0.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { execFileSync } from 'node:child_process';
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as path from 'node:path';
|
|
45
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
46
|
+
|
|
47
|
+
import { gitEnv } from './_lib/git_env.js';
|
|
48
|
+
import { splitMarkdownRow } from './_lib/md_table.js';
|
|
49
|
+
import { completionReviewDisabled } from './_lib/planning_settings.js';
|
|
50
|
+
import { DeadScopeError, assertScanned } from './_lib/scan_scope.js';
|
|
51
|
+
import {
|
|
52
|
+
computeReviewScope,
|
|
53
|
+
deriveSlug,
|
|
54
|
+
parseManifest,
|
|
55
|
+
reviewScopeNameOnlyArgs,
|
|
56
|
+
sanitizeSlug,
|
|
57
|
+
type ParsedManifest,
|
|
58
|
+
} from './dispatch_r2_reviewer.js';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Types
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface Violation {
|
|
65
|
+
kind: string;
|
|
66
|
+
file: string | null;
|
|
67
|
+
detail: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Marker {
|
|
71
|
+
reviewed: string;
|
|
72
|
+
/** The review-scope hash the review is bound to (§2.1) — 64 hex chars. */
|
|
73
|
+
scope: string;
|
|
74
|
+
/** Branch-head sha at review time. Provenance only, NEVER compared. */
|
|
75
|
+
diffSha: string;
|
|
76
|
+
reviewer: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface HonestNull {
|
|
80
|
+
scope: string;
|
|
81
|
+
reviewed: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SkipDeclaration {
|
|
85
|
+
reason: string;
|
|
86
|
+
scope: string; // 64-hex scope hash or the literal 'none' (empty scope only)
|
|
87
|
+
declared: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface FindingRow {
|
|
91
|
+
index: string;
|
|
92
|
+
severity: string;
|
|
93
|
+
fileLine: string;
|
|
94
|
+
finding: string;
|
|
95
|
+
status: string;
|
|
96
|
+
reasonRef: string;
|
|
97
|
+
line: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ParsedArtifact {
|
|
101
|
+
marker: Marker | null;
|
|
102
|
+
/** A `completion-review:` comment exists but does not match the §2.1 grammar. */
|
|
103
|
+
markerMalformed: boolean;
|
|
104
|
+
honestNull: HonestNull | null;
|
|
105
|
+
skip: SkipDeclaration | null;
|
|
106
|
+
rows: FindingRow[];
|
|
107
|
+
/** Lines that start like honest-null / skip but miss the exact grammar. */
|
|
108
|
+
malformedLines: string[];
|
|
109
|
+
/**
|
|
110
|
+
* Table-shaped lines that do not parse into the six §2.2 cells.
|
|
111
|
+
*
|
|
112
|
+
* Kept separate from {@link malformedLines} so the violation can be reported
|
|
113
|
+
* as `malformed-row`, matching Gate R1's `malformed_row` naming for the same
|
|
114
|
+
* defect class.
|
|
115
|
+
*/
|
|
116
|
+
malformedRows: string[];
|
|
117
|
+
/** The §5 context manifest, when present and parseable. */
|
|
118
|
+
manifest: ParsedManifest | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Pure grammar functions (exported for unit tests)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
// Contract §2.4. Deliberately broad: the suite installs into consumer repos of
|
|
126
|
+
// every stack, and a stack whose extension is missing here would classify a
|
|
127
|
+
// code-bearing completion as "no code surface" and accept a skip declaration.
|
|
128
|
+
const CODE_EXTENSIONS = new Set([
|
|
129
|
+
// JS / TS
|
|
130
|
+
'ts',
|
|
131
|
+
'tsx',
|
|
132
|
+
'js',
|
|
133
|
+
'jsx',
|
|
134
|
+
'mjs',
|
|
135
|
+
'cjs',
|
|
136
|
+
'mts',
|
|
137
|
+
'cts',
|
|
138
|
+
'vue',
|
|
139
|
+
'svelte',
|
|
140
|
+
// Python / Ruby / PHP / Perl
|
|
141
|
+
'py',
|
|
142
|
+
'pyi',
|
|
143
|
+
'rb',
|
|
144
|
+
'rake',
|
|
145
|
+
'php',
|
|
146
|
+
'pl',
|
|
147
|
+
'pm',
|
|
148
|
+
// JVM / .NET
|
|
149
|
+
'java',
|
|
150
|
+
'kt',
|
|
151
|
+
'kts',
|
|
152
|
+
'scala',
|
|
153
|
+
'groovy',
|
|
154
|
+
'cs',
|
|
155
|
+
'fs',
|
|
156
|
+
// Native
|
|
157
|
+
'c',
|
|
158
|
+
'h',
|
|
159
|
+
'cc',
|
|
160
|
+
'cpp',
|
|
161
|
+
'cxx',
|
|
162
|
+
'hpp',
|
|
163
|
+
'hh',
|
|
164
|
+
'm',
|
|
165
|
+
'mm',
|
|
166
|
+
'swift',
|
|
167
|
+
'go',
|
|
168
|
+
'rs',
|
|
169
|
+
'zig',
|
|
170
|
+
// Shell / scripting
|
|
171
|
+
'sh',
|
|
172
|
+
'bash',
|
|
173
|
+
'zsh',
|
|
174
|
+
'fish',
|
|
175
|
+
'ps1',
|
|
176
|
+
'lua',
|
|
177
|
+
// Data / templates that carry executable behaviour
|
|
178
|
+
'sql',
|
|
179
|
+
'ex',
|
|
180
|
+
'exs',
|
|
181
|
+
'erl',
|
|
182
|
+
'dart',
|
|
183
|
+
'r',
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Template extensions whose *inner* extension decides. `foo.blade.php` is
|
|
188
|
+
* already caught by `php`; `foo.html.twig` / `foo.j2` are not, so they are
|
|
189
|
+
* listed as whole suffixes.
|
|
190
|
+
*/
|
|
191
|
+
const CODE_SUFFIXES = ['.blade.php', '.html.twig', '.twig', '.j2', '.erb', '.hbs'];
|
|
192
|
+
|
|
193
|
+
export const SEVERITY_RANK: Readonly<Record<string, number>> = {
|
|
194
|
+
critical: 4,
|
|
195
|
+
high: 3,
|
|
196
|
+
medium: 2,
|
|
197
|
+
low: 1,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const VALID_STATUSES = new Set(['open', 'fixed', 'accepted-risk', 'deferred']);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Contract §2.4 code-path classification. `agents/**` never counts as code
|
|
204
|
+
* (the artifact itself lives there); `src/scripts/**` always does; everything
|
|
205
|
+
* else is decided by extension.
|
|
206
|
+
*/
|
|
207
|
+
export function isCodePath(p: string): boolean {
|
|
208
|
+
const norm = p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
209
|
+
if (norm === 'agents' || norm.startsWith('agents/')) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (norm.startsWith('src/scripts/')) {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
const base = norm.slice(norm.lastIndexOf('/') + 1).toLowerCase();
|
|
216
|
+
if (CODE_SUFFIXES.some((s) => base.endsWith(s))) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
const dot = base.lastIndexOf('.');
|
|
220
|
+
if (dot <= 0) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return CODE_EXTENSIONS.has(base.slice(dot + 1));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const MARKER_RE =
|
|
227
|
+
/^<!--\s*completion-review:\s*v1\s*\|\s*reviewed:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*scope:\s*([0-9a-fA-F]{64})\s*\|\s*diff:\s*([0-9a-fA-F]{7,40})\s*\|\s*reviewer:\s*(\S(?:[^|>]*[^|>\s])?)\s*-->$/;
|
|
228
|
+
|
|
229
|
+
export function parseMarkerLine(line: string): Marker | null {
|
|
230
|
+
const m = MARKER_RE.exec(line.trim());
|
|
231
|
+
if (!m) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
reviewed: m[1] as string,
|
|
236
|
+
scope: (m[2] as string).toLowerCase(),
|
|
237
|
+
diffSha: (m[3] as string).toLowerCase(),
|
|
238
|
+
reviewer: (m[4] as string).trim(),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const HONEST_NULL_RE = /^\*\*Honest-null:\*\* 0 findings, scope ([0-9a-fA-F]{64}), reviewed (\d{4}-\d{2}-\d{2})$/;
|
|
243
|
+
|
|
244
|
+
export function parseHonestNull(line: string): HonestNull | null {
|
|
245
|
+
const m = HONEST_NULL_RE.exec(line.trim());
|
|
246
|
+
if (!m) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return { scope: (m[1] as string).toLowerCase(), reviewed: m[2] as string };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const SKIP_RE =
|
|
253
|
+
/^\*\*Skipped:\*\* no code surface for this completion — (.+), scope ([0-9a-fA-F]{64}|none), declared (\d{4}-\d{2}-\d{2})$/;
|
|
254
|
+
|
|
255
|
+
export function parseSkipDeclaration(line: string): SkipDeclaration | null {
|
|
256
|
+
const m = SKIP_RE.exec(line.trim());
|
|
257
|
+
if (!m) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const reason = (m[1] as string).trim();
|
|
261
|
+
if (reason === '') {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return { reason, scope: (m[2] as string).toLowerCase(), declared: m[3] as string };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** First 7-40 char hex token in a Reason/Ref cell — the commit-ish, if any. */
|
|
268
|
+
export function extractFixRef(reasonRef: string): string | null {
|
|
269
|
+
const m = /\b[0-9a-f]{7,40}\b/i.exec(reasonRef);
|
|
270
|
+
return m ? (m[0] as string).toLowerCase() : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Does a leftover artifact in the reviews directory belong to THIS branch?
|
|
275
|
+
*
|
|
276
|
+
* Only own artifacts may produce violations. Without this, one malformed
|
|
277
|
+
* legacy artifact in the tracked reviews directory would block every
|
|
278
|
+
* subsequent PR (directory-wide poisoning). Containment is allowed in both
|
|
279
|
+
* directions so a roadmap-slug artifact (`road-to-x.findings.md`) still
|
|
280
|
+
* counts as own on branch `feat/road-to-x`; a 4-char floor keeps a stub like
|
|
281
|
+
* `feat` from matching everything.
|
|
282
|
+
*/
|
|
283
|
+
export function isOwnArtifactSlug(artifactSlug: string, branchSlug: string): boolean {
|
|
284
|
+
const a = artifactSlug.toLowerCase();
|
|
285
|
+
const b = branchSlug.toLowerCase();
|
|
286
|
+
if (a === '' || b === '') {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
if (a === b) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
const shorter = a.length <= b.length ? a : b;
|
|
293
|
+
if (shorter.length < 4) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return a.includes(b) || b.includes(a);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function splitTableRow(line: string): string[] {
|
|
300
|
+
return splitMarkdownRow(line);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const FENCE_RE = /^\s*```/;
|
|
304
|
+
|
|
305
|
+
export function parseArtifact(text: string): ParsedArtifact {
|
|
306
|
+
const out: ParsedArtifact = {
|
|
307
|
+
marker: null,
|
|
308
|
+
markerMalformed: false,
|
|
309
|
+
honestNull: null,
|
|
310
|
+
skip: null,
|
|
311
|
+
rows: [],
|
|
312
|
+
malformedLines: [],
|
|
313
|
+
malformedRows: [],
|
|
314
|
+
manifest: parseManifest(text),
|
|
315
|
+
};
|
|
316
|
+
const lines = text.split(/\r?\n/);
|
|
317
|
+
let inFence = false;
|
|
318
|
+
for (let i = 0; i < lines.length; i++) {
|
|
319
|
+
const raw = lines[i] as string;
|
|
320
|
+
const lineno = i + 1;
|
|
321
|
+
if (FENCE_RE.test(raw)) {
|
|
322
|
+
inFence = !inFence;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (inFence) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const trimmed = raw.trim();
|
|
329
|
+
// Marker candidates must LOOK like the §2.1 HTML comment: prose that
|
|
330
|
+
// merely mentions the grammar (`the \`completion-review:\` marker …`,
|
|
331
|
+
// or a reviewer note quoting it in backticks — real artefacts in this
|
|
332
|
+
// repo contain exactly that) is not a candidate at all. And a candidate
|
|
333
|
+
// that fails to parse is only malformed until a valid marker turns up
|
|
334
|
+
// later: without that, one quoted grammar line above the header
|
|
335
|
+
// permanently poisoned the artefact with a spurious `bad-marker`.
|
|
336
|
+
if (out.marker === null && trimmed.startsWith('<!--') && trimmed.includes('completion-review:')) {
|
|
337
|
+
const marker = parseMarkerLine(trimmed);
|
|
338
|
+
if (marker) {
|
|
339
|
+
out.marker = marker;
|
|
340
|
+
out.markerMalformed = false;
|
|
341
|
+
} else {
|
|
342
|
+
out.markerMalformed = true;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (trimmed.startsWith('**Honest-null:**')) {
|
|
347
|
+
const hn = parseHonestNull(trimmed);
|
|
348
|
+
if (hn) {
|
|
349
|
+
out.honestNull ??= hn;
|
|
350
|
+
} else {
|
|
351
|
+
out.malformedLines.push(`line ${lineno}: honest-null line does not match the exact §2.3 grammar`);
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (trimmed.startsWith('**Skipped:**')) {
|
|
356
|
+
const skip = parseSkipDeclaration(trimmed);
|
|
357
|
+
if (skip) {
|
|
358
|
+
out.skip ??= skip;
|
|
359
|
+
} else {
|
|
360
|
+
out.malformedLines.push(`line ${lineno}: skip declaration does not match the exact §2.4 grammar`);
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (trimmed.startsWith('|')) {
|
|
365
|
+
const cells = splitTableRow(trimmed);
|
|
366
|
+
if (cells.every((c) => /^[-: ]*$/.test(c))) {
|
|
367
|
+
continue; // separator row
|
|
368
|
+
}
|
|
369
|
+
const first = (cells[0] ?? '').toLowerCase();
|
|
370
|
+
const second = (cells[1] ?? '').toLowerCase();
|
|
371
|
+
if (first === '#' || second === 'severity') {
|
|
372
|
+
continue; // header row
|
|
373
|
+
}
|
|
374
|
+
if (cells.length >= 6) {
|
|
375
|
+
out.rows.push({
|
|
376
|
+
index: cells[0] ?? '',
|
|
377
|
+
severity: (cells[1] ?? '').toLowerCase(),
|
|
378
|
+
fileLine: cells[2] ?? '',
|
|
379
|
+
finding: cells[3] ?? '',
|
|
380
|
+
status: (cells[4] ?? '').toLowerCase(),
|
|
381
|
+
reasonRef: cells[5] ?? '',
|
|
382
|
+
line: lineno,
|
|
383
|
+
});
|
|
384
|
+
} else {
|
|
385
|
+
// A short row is NOT "not a findings row": dropping it silently
|
|
386
|
+
// let an `open` finding pass whenever one well-formed row kept
|
|
387
|
+
// `rows.length > 0` (so the neither-table-nor-honest-null
|
|
388
|
+
// fallback stayed quiet too). Omitting the trailing empty
|
|
389
|
+
// `Reason/Ref` cell is the likeliest authoring slip, since the
|
|
390
|
+
// §2.2 template ends in exactly that empty cell. Gate R1 already
|
|
391
|
+
// reports this class — same name, `malformed_row`.
|
|
392
|
+
out.malformedRows.push(
|
|
393
|
+
`line ${lineno}: findings row has ${String(cells.length)} cell(s), expected 6 ` +
|
|
394
|
+
'(`| # | Severity | File:Line | Finding | Status | Reason/Ref |` — the trailing ' +
|
|
395
|
+
'Reason/Ref cell is required even when empty)',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Row-level checks: bad-value, severity-order, open/deferred/accepted-risk gates. */
|
|
404
|
+
export function validateFindingRows(rows: readonly FindingRow[]): Array<{ kind: string; detail: string }> {
|
|
405
|
+
const out: Array<{ kind: string; detail: string }> = [];
|
|
406
|
+
let prevRank: number | null = null;
|
|
407
|
+
let orderReported = false;
|
|
408
|
+
for (const row of rows) {
|
|
409
|
+
const rank = SEVERITY_RANK[row.severity];
|
|
410
|
+
if (rank === undefined) {
|
|
411
|
+
out.push({
|
|
412
|
+
kind: 'bad-value',
|
|
413
|
+
detail: `row ${row.index} (line ${row.line}): unknown severity '${row.severity}' — expected critical|high|medium|low`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (!VALID_STATUSES.has(row.status)) {
|
|
417
|
+
out.push({
|
|
418
|
+
kind: 'bad-value',
|
|
419
|
+
detail: `row ${row.index} (line ${row.line}): unknown status '${row.status}' — expected open|fixed|accepted-risk|deferred`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (rank !== undefined) {
|
|
423
|
+
if (prevRank !== null && rank > prevRank && !orderReported) {
|
|
424
|
+
out.push({
|
|
425
|
+
kind: 'severity-order',
|
|
426
|
+
detail:
|
|
427
|
+
`row ${row.index} (line ${row.line}): severity '${row.severity}' outranks an earlier row — ` +
|
|
428
|
+
'rows must be sorted descending (critical > high > medium > low)',
|
|
429
|
+
});
|
|
430
|
+
orderReported = true;
|
|
431
|
+
}
|
|
432
|
+
prevRank = rank;
|
|
433
|
+
}
|
|
434
|
+
if (row.status === 'open') {
|
|
435
|
+
out.push({
|
|
436
|
+
kind: 'open-finding',
|
|
437
|
+
detail: `row ${row.index} (line ${row.line}): finding is still 'open' — every finding must reach a terminal status`,
|
|
438
|
+
});
|
|
439
|
+
} else if (row.status === 'deferred' && row.reasonRef.trim() === '') {
|
|
440
|
+
out.push({
|
|
441
|
+
kind: 'deferred-without-ref',
|
|
442
|
+
detail: `row ${row.index} (line ${row.line}): 'deferred' without a ticket/issue/roadmap ref in Reason/Ref`,
|
|
443
|
+
});
|
|
444
|
+
} else if (row.status === 'accepted-risk' && row.reasonRef.trim() === '') {
|
|
445
|
+
out.push({
|
|
446
|
+
kind: 'accepted-risk-without-reason',
|
|
447
|
+
detail: `row ${row.index} (line ${row.line}): 'accepted-risk' without a reason in Reason/Ref`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Git plumbing
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
class InternalError extends Error {}
|
|
459
|
+
|
|
460
|
+
interface GitResult {
|
|
461
|
+
ok: boolean;
|
|
462
|
+
stdout: string;
|
|
463
|
+
status: number;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The review-scope diff is read through this runner, and Node's default
|
|
468
|
+
* `maxBuffer` is 1 MiB — a branch whose scope diff exceeds that throws ENOBUFS,
|
|
469
|
+
* which surfaces as exit 2 and makes every caller warn-and-allow. Gate R2 would
|
|
470
|
+
* then silently self-disable on exactly the large PRs that most need it. The
|
|
471
|
+
* dispatcher already reads the identical diff with this ceiling; the shared
|
|
472
|
+
* scope definition is only as strong as the weakest injected git runner.
|
|
473
|
+
*/
|
|
474
|
+
export const GIT_MAX_BUFFER = 256 * 1024 * 1024;
|
|
475
|
+
|
|
476
|
+
function gitTry(repo: string, args: readonly string[]): GitResult {
|
|
477
|
+
try {
|
|
478
|
+
const stdout = execFileSync('git', args as string[], {
|
|
479
|
+
cwd: repo,
|
|
480
|
+
encoding: 'utf8',
|
|
481
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
482
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
483
|
+
// cwd decides, never an inherited GIT_DIR (hook environments).
|
|
484
|
+
env: gitEnv(),
|
|
485
|
+
});
|
|
486
|
+
return { ok: true, stdout, status: 0 };
|
|
487
|
+
} catch (exc) {
|
|
488
|
+
const status = (exc as { status?: unknown }).status;
|
|
489
|
+
return { ok: false, stdout: '', status: typeof status === 'number' ? status : -1 };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function gitOut(repo: string, args: readonly string[]): string {
|
|
494
|
+
const r = gitTry(repo, args);
|
|
495
|
+
if (!r.ok) {
|
|
496
|
+
throw new InternalError(`git ${args.join(' ')} failed (exit ${r.status}) in ${repo}`);
|
|
497
|
+
}
|
|
498
|
+
return r.stdout;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// Gate evaluation
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
interface ArtifactFile {
|
|
506
|
+
file: string; // absolute path
|
|
507
|
+
art: ParsedArtifact;
|
|
508
|
+
/** `<slug>.findings.md` → `<slug>`, sanitized. */
|
|
509
|
+
slug: string;
|
|
510
|
+
/** This branch's artifact (see {@link isOwnArtifactSlug}). */
|
|
511
|
+
own: boolean;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
interface EvalInput {
|
|
515
|
+
repo: string;
|
|
516
|
+
head: string;
|
|
517
|
+
/** sha256 of the review-scope diff — what the review binds to (§2.1). */
|
|
518
|
+
scopeHash: string;
|
|
519
|
+
/** The review scope is empty — the only state a `scope none` skip covers. */
|
|
520
|
+
scopeEmpty: boolean;
|
|
521
|
+
changed: readonly string[];
|
|
522
|
+
artifacts: readonly ArtifactFile[];
|
|
523
|
+
artifactDirLabel: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Does this artifact claim the CURRENT review scope? */
|
|
527
|
+
function claimsScope(art: ParsedArtifact, scopeHash: string, scopeEmpty: boolean): boolean {
|
|
528
|
+
if (art.marker !== null && art.marker.scope === scopeHash) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
if (art.honestNull !== null && art.honestNull.scope === scopeHash) {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
if (art.skip !== null) {
|
|
535
|
+
// `scope none` is valid ONLY for a genuinely empty review scope.
|
|
536
|
+
// Without that condition one committed `scope none` skip would satisfy
|
|
537
|
+
// the gate for every later diff, forever.
|
|
538
|
+
return art.skip.scope === 'none' ? scopeEmpty : art.skip.scope === scopeHash;
|
|
539
|
+
}
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Does the artifact carry a review (header marker, findings table, or
|
|
545
|
+
* honest-null line)? §5 makes a context manifest mandatory for one; a bare §2.4
|
|
546
|
+
* skip declaration carries no reviewer dispatch and needs none.
|
|
547
|
+
*/
|
|
548
|
+
function artifactCarriesReview(art: ParsedArtifact): boolean {
|
|
549
|
+
return art.marker !== null || art.rows.length > 0 || art.honestNull !== null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export interface ArtifactRelevance {
|
|
553
|
+
/** Claims the CURRENT review scope — §2.6 relevance. */
|
|
554
|
+
relevant: boolean;
|
|
555
|
+
/** Carries a review, so §5 requires a manifest that can be re-derived. */
|
|
556
|
+
carriesReview: boolean;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The §2.6 relevance notion, exported so a CI re-derivation step can SELECT the
|
|
561
|
+
* artefacts to verify instead of verifying the whole accumulating directory
|
|
562
|
+
* (`dispatch_r2_reviewer --verify-current`). Restating the notion there is what
|
|
563
|
+
* would silently re-break the gate, so it lives here once.
|
|
564
|
+
*/
|
|
565
|
+
export function artifactRelevance(text: string, scopeHash: string, scopeEmpty: boolean): ArtifactRelevance {
|
|
566
|
+
const art = parseArtifact(text);
|
|
567
|
+
return {
|
|
568
|
+
relevant: claimsScope(art, scopeHash, scopeEmpty),
|
|
569
|
+
carriesReview: artifactCarriesReview(art),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function evaluate(input: EvalInput): Violation[] {
|
|
574
|
+
const { repo, head, scopeHash, scopeEmpty, changed, artifacts } = input;
|
|
575
|
+
const violations: Violation[] = [];
|
|
576
|
+
const codePaths = changed.filter(isCodePath);
|
|
577
|
+
|
|
578
|
+
const relevant = artifacts.filter(({ art }) => claimsScope(art, scopeHash, scopeEmpty));
|
|
579
|
+
const relevantFiles = new Set(relevant.map(({ file }) => file));
|
|
580
|
+
|
|
581
|
+
// Grammar violations are reported ONLY for artifacts that are this
|
|
582
|
+
// branch's (own slug) or that claim the current scope. A foreign or
|
|
583
|
+
// legacy artifact sitting in the tracked reviews directory must not be
|
|
584
|
+
// able to block an unrelated PR — that is directory-wide poisoning.
|
|
585
|
+
for (const { file, art, own } of artifacts) {
|
|
586
|
+
if (!own && !relevantFiles.has(file)) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (art.markerMalformed) {
|
|
590
|
+
violations.push({
|
|
591
|
+
kind: 'bad-marker',
|
|
592
|
+
file,
|
|
593
|
+
detail:
|
|
594
|
+
'completion-review header marker malformed — expected ' +
|
|
595
|
+
'`<!-- completion-review: v1 | reviewed: YYYY-MM-DD | scope: <64-hex scope hash> | ' +
|
|
596
|
+
'diff: <sha> | reviewer: <id> -->`',
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
for (const detail of art.malformedLines) {
|
|
600
|
+
violations.push({ kind: 'bad-value', file, detail });
|
|
601
|
+
}
|
|
602
|
+
// §2.2: a table-shaped line that does not carry the six cells is a
|
|
603
|
+
// violation, never a row to skip — an invisible row is an unreviewed
|
|
604
|
+
// finding that passes.
|
|
605
|
+
for (const detail of art.malformedRows) {
|
|
606
|
+
violations.push({ kind: 'malformed-row', file, detail });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for (const { file, art } of relevant) {
|
|
611
|
+
const carriesReview = artifactCarriesReview(art);
|
|
612
|
+
|
|
613
|
+
// A skip and a findings table cannot both hold. Reported, and the
|
|
614
|
+
// table is still validated — a skip line must never suppress rows.
|
|
615
|
+
if (art.skip !== null && art.rows.length > 0) {
|
|
616
|
+
violations.push({
|
|
617
|
+
kind: 'bad-value',
|
|
618
|
+
file,
|
|
619
|
+
detail:
|
|
620
|
+
'contradictory artifact: a `**Skipped:**` declaration and a findings table are both ' +
|
|
621
|
+
'present — a completion is either skip-eligible (no code surface) or reviewed, never both',
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (art.skip !== null && codePaths.length > 0) {
|
|
626
|
+
violations.push({
|
|
627
|
+
kind: 'skip-on-code-diff',
|
|
628
|
+
file,
|
|
629
|
+
detail:
|
|
630
|
+
`skip declaration present but the diff touches ${codePaths.length} code path(s) ` +
|
|
631
|
+
`(e.g. ${codePaths.slice(0, 3).join(', ')}) — a code diff requires a findings artifact`,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// §2.1: every header field is mandatory. A review-bearing artifact with
|
|
636
|
+
// NO parseable header marker at all is malformed — not "no marker,
|
|
637
|
+
// therefore nothing to check". A marker-SHAPED line that failed to
|
|
638
|
+
// parse is already reported above, so this reports once (§2.6).
|
|
639
|
+
if (carriesReview && art.marker === null && !art.markerMalformed) {
|
|
640
|
+
violations.push({
|
|
641
|
+
kind: 'bad-marker',
|
|
642
|
+
file,
|
|
643
|
+
detail:
|
|
644
|
+
'findings artifact carries a review but no `completion-review: v1` header marker — the §2.1 ' +
|
|
645
|
+
'header is mandatory: `<!-- completion-review: v1 | reviewed: YYYY-MM-DD | ' +
|
|
646
|
+
'scope: <64-hex scope hash> | diff: <sha> | reviewer: <id> -->`',
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// §2.1: the header `scope:` is the ONLY field staleness is decided on.
|
|
651
|
+
// Relevance ORs header / honest-null / skip, so without this an artifact
|
|
652
|
+
// whose header still points at an older scope, while its honest-null
|
|
653
|
+
// line or manifest carries the current one, was relevant AND stale — and
|
|
654
|
+
// passed both gates.
|
|
655
|
+
if (art.marker !== null && art.marker.scope !== scopeHash) {
|
|
656
|
+
violations.push({
|
|
657
|
+
kind: 'stale-review',
|
|
658
|
+
file,
|
|
659
|
+
detail:
|
|
660
|
+
`header declares scope ${art.marker.scope} but the current review scope is ${scopeHash} — ` +
|
|
661
|
+
'the header `scope:` is the only field staleness is decided on (contract §2.1)',
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// §5: the manifest is verification, not self-attestation — an artifact
|
|
666
|
+
// without one is unverifiable, so its absence is a policy violation
|
|
667
|
+
// rather than something to tolerate. A bare skip declaration carries
|
|
668
|
+
// no reviewer dispatch and needs none. When both are present the
|
|
669
|
+
// manifest's `scope_hash` MUST agree with the header's `scope:` (§5);
|
|
670
|
+
// staleness itself is the header check above, so agreement is all that
|
|
671
|
+
// is left to verify here.
|
|
672
|
+
if (carriesReview && art.manifest === null) {
|
|
673
|
+
violations.push({
|
|
674
|
+
kind: 'missing-manifest',
|
|
675
|
+
file,
|
|
676
|
+
detail:
|
|
677
|
+
'findings artifact carries no parseable `context-manifest: v1` block — the §5 manifest ' +
|
|
678
|
+
'is mandatory (verification, not self-attestation); re-dispatch via dispatch_r2_reviewer',
|
|
679
|
+
});
|
|
680
|
+
} else if (art.manifest !== null && art.marker !== null && art.manifest.scope_hash !== art.marker.scope) {
|
|
681
|
+
violations.push({
|
|
682
|
+
kind: 'manifest-header-mismatch',
|
|
683
|
+
file,
|
|
684
|
+
detail:
|
|
685
|
+
`manifest records scope_hash ${art.manifest.scope_hash} but the header declares scope ` +
|
|
686
|
+
`${art.marker.scope} — the two must agree (contract §5); re-dispatch via dispatch_r2_reviewer`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (art.honestNull !== null && art.marker !== null && art.honestNull.scope !== scopeHash) {
|
|
691
|
+
violations.push({
|
|
692
|
+
kind: 'stale-review',
|
|
693
|
+
file,
|
|
694
|
+
detail: `honest-null declares scope ${art.honestNull.scope} but the current review scope is ${scopeHash}`,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (art.rows.length > 0) {
|
|
699
|
+
for (const v of validateFindingRows(art.rows)) {
|
|
700
|
+
violations.push({ ...v, file });
|
|
701
|
+
}
|
|
702
|
+
violations.push(...checkFindingsBeforeFixes(repo, file, art.rows));
|
|
703
|
+
} else if (art.honestNull === null && art.skip === null) {
|
|
704
|
+
violations.push({
|
|
705
|
+
kind: 'bad-value',
|
|
706
|
+
file,
|
|
707
|
+
detail: 'artifact has neither a findings table nor an honest-null / skip declaration line',
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (relevant.length === 0) {
|
|
713
|
+
const ownArtifacts = artifacts.filter(({ own }) => own);
|
|
714
|
+
const ownParseable = ownArtifacts.filter(
|
|
715
|
+
({ art }) => art.marker !== null || art.honestNull !== null || art.skip !== null,
|
|
716
|
+
);
|
|
717
|
+
const anyOwnBadMarker = ownArtifacts.some(({ art }) => art.markerMalformed);
|
|
718
|
+
if (ownParseable.length > 0) {
|
|
719
|
+
const scopes = ownParseable.map(
|
|
720
|
+
({ art }) => art.marker?.scope ?? art.skip?.scope ?? art.honestNull?.scope ?? '?',
|
|
721
|
+
);
|
|
722
|
+
violations.push({
|
|
723
|
+
kind: 'stale-review',
|
|
724
|
+
file: (ownParseable[0] as ArtifactFile).file,
|
|
725
|
+
detail:
|
|
726
|
+
`no artifact matches the current review scope ${scopeHash} (head ${head}) — found ` +
|
|
727
|
+
`artifact(s) for scope(s): ${scopes.join(', ')}. A change to the reviewed content forces ` +
|
|
728
|
+
're-review (contract §2.1).',
|
|
729
|
+
});
|
|
730
|
+
} else if (!anyOwnBadMarker) {
|
|
731
|
+
violations.push({
|
|
732
|
+
kind: 'missing-artifact',
|
|
733
|
+
file: null,
|
|
734
|
+
detail:
|
|
735
|
+
`no completion-review artifact for review scope ${scopeHash} (head ${head}) under ` +
|
|
736
|
+
`${input.artifactDirLabel} and no valid skip declaration (diff has ${codePaths.length} ` +
|
|
737
|
+
`code path(s) of ${changed.length} changed file(s))`,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return violations;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Contract §2.5 — the artifact's FIRST-add commit must precede every fix commit. */
|
|
746
|
+
function checkFindingsBeforeFixes(repo: string, artifactAbs: string, rows: readonly FindingRow[]): Violation[] {
|
|
747
|
+
const violations: Violation[] = [];
|
|
748
|
+
const fixedRows = rows.filter((r) => r.status === 'fixed');
|
|
749
|
+
if (fixedRows.length === 0) {
|
|
750
|
+
return violations;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// `/`-normalized: a raw Windows separator is not a valid git pathspec, and
|
|
754
|
+
// an empty result here reads as `artifact-not-committed` on a properly
|
|
755
|
+
// committed artifact.
|
|
756
|
+
const rel = path.relative(repo, artifactAbs).split(path.sep).join('/');
|
|
757
|
+
// Earliest add commit: `git log --diff-filter=A` lists newest-first, so the
|
|
758
|
+
// LAST line is the first commit that ever added the file (backdating via a
|
|
759
|
+
// later amend does not move it).
|
|
760
|
+
const logRes = gitTry(repo, ['log', '--diff-filter=A', '--format=%H', '--', rel]);
|
|
761
|
+
const addLines = logRes.stdout
|
|
762
|
+
.split('\n')
|
|
763
|
+
.map((l) => l.trim())
|
|
764
|
+
.filter(Boolean);
|
|
765
|
+
const addSha = addLines.length > 0 ? (addLines[addLines.length - 1] as string) : null;
|
|
766
|
+
|
|
767
|
+
let notCommittedReported = false;
|
|
768
|
+
for (const row of fixedRows) {
|
|
769
|
+
const token = extractFixRef(row.reasonRef);
|
|
770
|
+
if (token === null) {
|
|
771
|
+
violations.push({
|
|
772
|
+
kind: 'unresolvable-fix-ref',
|
|
773
|
+
file: artifactAbs,
|
|
774
|
+
detail: `row ${row.index} (line ${row.line}): 'fixed' without a commit ref in Reason/Ref`,
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const resolved = gitTry(repo, ['rev-parse', '--verify', '--quiet', `${token}^{commit}`]);
|
|
779
|
+
if (!resolved.ok) {
|
|
780
|
+
violations.push({
|
|
781
|
+
kind: 'unresolvable-fix-ref',
|
|
782
|
+
file: artifactAbs,
|
|
783
|
+
detail: `row ${row.index} (line ${row.line}): fix ref '${token}' does not resolve to a commit`,
|
|
784
|
+
});
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const fixSha = resolved.stdout.trim();
|
|
788
|
+
if (addSha === null) {
|
|
789
|
+
if (!notCommittedReported) {
|
|
790
|
+
violations.push({
|
|
791
|
+
kind: 'artifact-not-committed',
|
|
792
|
+
file: artifactAbs,
|
|
793
|
+
detail:
|
|
794
|
+
'artifact is untracked/uncommitted but marks findings fixed with commit refs — ' +
|
|
795
|
+
'commit the findings artifact BEFORE the fix commits (contract §2.5)',
|
|
796
|
+
});
|
|
797
|
+
notCommittedReported = true;
|
|
798
|
+
}
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const ancestry = gitTry(repo, ['merge-base', '--is-ancestor', addSha, fixSha]);
|
|
802
|
+
if (ancestry.ok) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (ancestry.status === 1) {
|
|
806
|
+
violations.push({
|
|
807
|
+
kind: 'fix-before-artifact',
|
|
808
|
+
file: artifactAbs,
|
|
809
|
+
detail:
|
|
810
|
+
`row ${row.index} (line ${row.line}): fix commit ${fixSha.slice(0, 12)} predates the ` +
|
|
811
|
+
`artifact's first-add commit ${addSha.slice(0, 12)} — findings must be committed before fixes ` +
|
|
812
|
+
'(contract §2.5; the first-add commit is what counts, so backdating is detected)',
|
|
813
|
+
});
|
|
814
|
+
} else {
|
|
815
|
+
throw new InternalError(
|
|
816
|
+
`git merge-base --is-ancestor ${addSha} ${fixSha} failed (exit ${ancestry.status})`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return violations;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// CLI
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
interface Args {
|
|
828
|
+
base: string;
|
|
829
|
+
advisory: boolean;
|
|
830
|
+
format: 'text' | 'json';
|
|
831
|
+
quiet: boolean;
|
|
832
|
+
artifactDir: string;
|
|
833
|
+
repo: string;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const USAGE =
|
|
837
|
+
'usage: check_completion_review [-h] [--base REF] [--advisory] [--format {text,json}] ' +
|
|
838
|
+
'[--quiet] [--artifact-dir DIR] [--repo DIR]\n';
|
|
839
|
+
|
|
840
|
+
function parseArgs(argv: readonly string[]): Args {
|
|
841
|
+
const args: Args = {
|
|
842
|
+
base: 'origin/main',
|
|
843
|
+
advisory: false,
|
|
844
|
+
format: 'text',
|
|
845
|
+
quiet: false,
|
|
846
|
+
artifactDir: 'agents/evidence/reviews',
|
|
847
|
+
repo: '.',
|
|
848
|
+
};
|
|
849
|
+
const takeValue = (argvArr: readonly string[], i: number, name: string): string => {
|
|
850
|
+
const v = argvArr[i];
|
|
851
|
+
if (v === undefined) {
|
|
852
|
+
process.stderr.write(`check_completion_review: error: argument ${name}: expected one value\n`);
|
|
853
|
+
process.exit(2);
|
|
854
|
+
}
|
|
855
|
+
return v;
|
|
856
|
+
};
|
|
857
|
+
let i = 0;
|
|
858
|
+
while (i < argv.length) {
|
|
859
|
+
const arg = argv[i] as string;
|
|
860
|
+
if (arg === '--base') {
|
|
861
|
+
args.base = takeValue(argv, ++i, '--base');
|
|
862
|
+
} else if (arg.startsWith('--base=')) {
|
|
863
|
+
args.base = arg.slice('--base='.length);
|
|
864
|
+
} else if (arg === '--advisory') {
|
|
865
|
+
args.advisory = true;
|
|
866
|
+
} else if (arg === '--quiet') {
|
|
867
|
+
args.quiet = true;
|
|
868
|
+
} else if (arg === '--artifact-dir') {
|
|
869
|
+
args.artifactDir = takeValue(argv, ++i, '--artifact-dir');
|
|
870
|
+
} else if (arg.startsWith('--artifact-dir=')) {
|
|
871
|
+
args.artifactDir = arg.slice('--artifact-dir='.length);
|
|
872
|
+
} else if (arg === '--repo') {
|
|
873
|
+
args.repo = takeValue(argv, ++i, '--repo');
|
|
874
|
+
} else if (arg.startsWith('--repo=')) {
|
|
875
|
+
args.repo = arg.slice('--repo='.length);
|
|
876
|
+
} else if (arg === '--format' || arg.startsWith('--format=')) {
|
|
877
|
+
const v = arg === '--format' ? takeValue(argv, ++i, '--format') : arg.slice('--format='.length);
|
|
878
|
+
if (v !== 'text' && v !== 'json') {
|
|
879
|
+
process.stderr.write(
|
|
880
|
+
`check_completion_review: error: argument --format: invalid choice: '${v}' (choose from 'text', 'json')\n`,
|
|
881
|
+
);
|
|
882
|
+
process.exit(2);
|
|
883
|
+
}
|
|
884
|
+
args.format = v;
|
|
885
|
+
} else if (arg === '-h' || arg === '--help') {
|
|
886
|
+
process.stdout.write(USAGE);
|
|
887
|
+
process.exit(0);
|
|
888
|
+
} else {
|
|
889
|
+
process.stderr.write(`check_completion_review: error: unrecognized arguments: ${arg}\n`);
|
|
890
|
+
process.exit(2);
|
|
891
|
+
}
|
|
892
|
+
i++;
|
|
893
|
+
}
|
|
894
|
+
return args;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function report(args: Args, violations: readonly Violation[], passNote: string | null): number {
|
|
898
|
+
if (args.format === 'json') {
|
|
899
|
+
process.stdout.write(JSON.stringify(violations, null, 2) + '\n');
|
|
900
|
+
} else if (violations.length === 0) {
|
|
901
|
+
if (!args.quiet) {
|
|
902
|
+
process.stdout.write(passNote ?? '✅ Completion review clean.\n');
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
const head = args.advisory
|
|
906
|
+
? `⚠️ ${violations.length} completion-review violation(s) (advisory — not blocking):\n\n`
|
|
907
|
+
: `❌ ${violations.length} completion-review violation(s):\n\n`;
|
|
908
|
+
process.stdout.write(head);
|
|
909
|
+
const prefix = args.advisory ? '⚠️ (advisory) ' : '';
|
|
910
|
+
for (const v of violations) {
|
|
911
|
+
process.stdout.write(` ${prefix}${v.kind}${v.file !== null ? ` — ${v.file}` : ''}\n`);
|
|
912
|
+
process.stdout.write(` │ ${v.detail}\n`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (violations.length === 0) {
|
|
916
|
+
return 0;
|
|
917
|
+
}
|
|
918
|
+
return args.advisory ? 0 : 1;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* `planning.completion_review === false` in `<dir>/.agent-settings.yml` disables
|
|
923
|
+
* Gate R2. Parsing mirrors Gate R1's `riskReviewDisabled` exactly (same file,
|
|
924
|
+
* same fail-open-on-unreadable/unparseable behaviour, same strict `=== false`
|
|
925
|
+
* test) so a missing key or a missing file leaves the gate ACTIVE.
|
|
926
|
+
*
|
|
927
|
+
* Re-exported from `_lib/planning_settings.ts`: the dispatcher's `--verify`
|
|
928
|
+
* layer must honour the same hatch, and it cannot import from this module
|
|
929
|
+
* (this module imports the scope hash from it).
|
|
930
|
+
*/
|
|
931
|
+
export { completionReviewDisabled };
|
|
932
|
+
|
|
933
|
+
/** Is `rel` a tracked directory in `ref`? Absolute paths are never tracked. */
|
|
934
|
+
function isTrackedTree(repo: string, ref: string, rel: string): boolean {
|
|
935
|
+
if (path.isAbsolute(rel)) {
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
return gitTry(repo, ['ls-tree', '-d', '--name-only', ref, '--', rel]).stdout.trim() !== '';
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* When is an unresolvable artefact root NOT a dead scope?
|
|
943
|
+
*
|
|
944
|
+
* The dead-scope assertion exists to catch a MOVED root (the ADR-051 class:
|
|
945
|
+
* fourteen gates kept scanning a path a migration had renamed, and reported
|
|
946
|
+
* green). Two states are legitimately empty instead:
|
|
947
|
+
* - the review scope is empty — there is nothing to review at all;
|
|
948
|
+
* - the root is absent AND was never tracked — a repo with no review corpus
|
|
949
|
+
* yet. That state still blocks, via the actionable `missing-artifact`.
|
|
950
|
+
*
|
|
951
|
+
* A root that IS tracked but no longer resolves on disk is the real defect and
|
|
952
|
+
* gets no exemption.
|
|
953
|
+
*
|
|
954
|
+
* @returns the `allowEmpty` option to spread, or `null` to let it throw.
|
|
955
|
+
*/
|
|
956
|
+
function deadScopeExemption(
|
|
957
|
+
repo: string,
|
|
958
|
+
args: Args,
|
|
959
|
+
state: { scopeEmpty: boolean; artifactRootResolved: boolean },
|
|
960
|
+
): { allowEmpty: string } | null {
|
|
961
|
+
if (state.scopeEmpty) {
|
|
962
|
+
return { allowEmpty: 'review scope is empty — no reviewable diff vs base' };
|
|
963
|
+
}
|
|
964
|
+
if (state.artifactRootResolved) {
|
|
965
|
+
return null; // scanned >= 1 anyway; the assertion will not fire
|
|
966
|
+
}
|
|
967
|
+
const tracked =
|
|
968
|
+
isTrackedTree(repo, args.base, args.artifactDir) || isTrackedTree(repo, 'HEAD', args.artifactDir);
|
|
969
|
+
if (tracked) {
|
|
970
|
+
return null; // tracked root that no longer resolves → dead scope
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
allowEmpty: `artefact root '${args.artifactDir}' is absent and untracked — no review corpus in this repo yet`,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function run(args: Args): number {
|
|
978
|
+
const repo = path.resolve(args.repo);
|
|
979
|
+
|
|
980
|
+
// Settings escape hatch (contract § Scope): `planning.completion_review:
|
|
981
|
+
// false` disables Gate R2 — documented in the settings template, the Zod
|
|
982
|
+
// schema and the /create-pr surface, so it has to actually work. Mirrors
|
|
983
|
+
// Gate R1's `riskReviewDisabled`, including the explicit `scanned: 0`:
|
|
984
|
+
// the zero-scan is a configured skip, not a dead scope.
|
|
985
|
+
if (completionReviewDisabled(repo)) {
|
|
986
|
+
process.stdout.write('⚠️ planning.completion_review=false — Gate R2 skipped (settings escape hatch)\n');
|
|
987
|
+
process.stdout.write('scanned: 0\n');
|
|
988
|
+
return 0;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// The artefact inventory needs no git, so it is resolved FIRST: the
|
|
992
|
+
// gate-coverage guard parses `scanned:` and must get a number on EVERY exit
|
|
993
|
+
// path, including the exit-2 internal-error one below (an unresolvable base
|
|
994
|
+
// ref used to exit 2 emitting no count at all).
|
|
995
|
+
const artifactDirAbs = path.isAbsolute(args.artifactDir) ? args.artifactDir : path.join(repo, args.artifactDir);
|
|
996
|
+
let artifactRootResolved = false;
|
|
997
|
+
let artifactPaths: string[] = [];
|
|
998
|
+
try {
|
|
999
|
+
if (fs.statSync(artifactDirAbs).isDirectory()) {
|
|
1000
|
+
artifactRootResolved = true;
|
|
1001
|
+
artifactPaths = fs
|
|
1002
|
+
.readdirSync(artifactDirAbs)
|
|
1003
|
+
.filter((n) => n.endsWith('.findings.md'))
|
|
1004
|
+
.sort()
|
|
1005
|
+
.map((n) => path.join(artifactDirAbs, n));
|
|
1006
|
+
}
|
|
1007
|
+
} catch {
|
|
1008
|
+
// Root missing → handled by the dead-scope check below, never silently.
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Gate-coverage contract: `scanned:` is emitted BEFORE the pass/fail branch
|
|
1012
|
+
// (coverage and verdict are different questions). N counts the artefacts
|
|
1013
|
+
// inspected plus 1 for the diff evaluation — and that +1 is counted ONLY
|
|
1014
|
+
// when the artefact root actually resolves, so a moved/renamed root drops
|
|
1015
|
+
// N to 0 instead of hiding behind a floor that cannot fail.
|
|
1016
|
+
const scanned = artifactPaths.length + (artifactRootResolved ? 1 : 0);
|
|
1017
|
+
process.stdout.write(`scanned: ${scanned}\n`);
|
|
1018
|
+
|
|
1019
|
+
const baseCheck = gitTry(repo, ['rev-parse', '--verify', '--quiet', `${args.base}^{commit}`]);
|
|
1020
|
+
if (!baseCheck.ok) {
|
|
1021
|
+
throw new InternalError(`base ref '${args.base}' not found in ${repo}`);
|
|
1022
|
+
}
|
|
1023
|
+
const head = gitOut(repo, ['rev-parse', 'HEAD']).trim().toLowerCase();
|
|
1024
|
+
// The REVIEW SCOPE excludes the review artefacts themselves, so committing
|
|
1025
|
+
// the findings artifact (§2.5) cannot invalidate the review it records.
|
|
1026
|
+
const scope = computeReviewScope((a) => gitOut(repo, a), args.base);
|
|
1027
|
+
const scopeHash = scope.hash;
|
|
1028
|
+
const scopeEmpty = scope.empty;
|
|
1029
|
+
const changed = gitOut(repo, reviewScopeNameOnlyArgs(args.base))
|
|
1030
|
+
.split('\n')
|
|
1031
|
+
.map((l) => l.trim())
|
|
1032
|
+
.filter(Boolean);
|
|
1033
|
+
const branchSlug = deriveSlug((a) => gitOut(repo, a));
|
|
1034
|
+
|
|
1035
|
+
try {
|
|
1036
|
+
assertScanned({
|
|
1037
|
+
gate: 'check_completion_review',
|
|
1038
|
+
scanned,
|
|
1039
|
+
units: 'review artefact(s) + diff evaluation',
|
|
1040
|
+
roots: [args.artifactDir],
|
|
1041
|
+
...(deadScopeExemption(repo, args, { scopeEmpty, artifactRootResolved }) ?? {}),
|
|
1042
|
+
});
|
|
1043
|
+
} catch (exc) {
|
|
1044
|
+
if (!(exc instanceof DeadScopeError)) {
|
|
1045
|
+
throw exc;
|
|
1046
|
+
}
|
|
1047
|
+
// Contract §6 carve-out: a dead scan scope is a POLICY violation, not
|
|
1048
|
+
// an internal error. Mapping it to exit 2 would make every caller
|
|
1049
|
+
// warn-and-allow, silently degrading the gate to advisory the moment
|
|
1050
|
+
// the artefact root moves.
|
|
1051
|
+
return report(
|
|
1052
|
+
args,
|
|
1053
|
+
[
|
|
1054
|
+
{
|
|
1055
|
+
kind: 'dead-scan-scope',
|
|
1056
|
+
file: null,
|
|
1057
|
+
detail:
|
|
1058
|
+
`${exc.message} (artefact root '${args.artifactDir}' does not resolve under ${repo}; ` +
|
|
1059
|
+
'create it or repoint --artifact-dir — a gate that read nothing has not passed)',
|
|
1060
|
+
},
|
|
1061
|
+
],
|
|
1062
|
+
null,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (scopeEmpty) {
|
|
1067
|
+
return report(args, [], `✅ No reviewable changes vs ${args.base} — nothing to review.\n`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const artifacts: ArtifactFile[] = artifactPaths.map((file) => {
|
|
1071
|
+
let text: string;
|
|
1072
|
+
try {
|
|
1073
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
1074
|
+
} catch (exc) {
|
|
1075
|
+
throw new InternalError(`unreadable artifact ${file}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
1076
|
+
}
|
|
1077
|
+
const slug = sanitizeSlug(path.basename(file).replace(/\.findings\.md$/, ''));
|
|
1078
|
+
return { file, art: parseArtifact(text), slug, own: isOwnArtifactSlug(slug, branchSlug) };
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
const violations = evaluate({
|
|
1082
|
+
repo,
|
|
1083
|
+
head,
|
|
1084
|
+
scopeHash,
|
|
1085
|
+
scopeEmpty,
|
|
1086
|
+
changed,
|
|
1087
|
+
artifacts,
|
|
1088
|
+
artifactDirLabel: args.artifactDir,
|
|
1089
|
+
});
|
|
1090
|
+
if (violations.length === 0) {
|
|
1091
|
+
return report(
|
|
1092
|
+
args,
|
|
1093
|
+
violations,
|
|
1094
|
+
`✅ Completion review clean (${artifactPaths.length} artefact(s), ${changed.length} changed file(s) vs ${args.base}).\n`,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
return report(args, violations, null);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function main(argv?: readonly string[]): number {
|
|
1101
|
+
const args = parseArgs(argv ?? process.argv.slice(2));
|
|
1102
|
+
try {
|
|
1103
|
+
return run(args);
|
|
1104
|
+
} catch (exc) {
|
|
1105
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
1106
|
+
process.stderr.write(`❌ Internal error: ${msg}\n`);
|
|
1107
|
+
return 2;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
1112
|
+
function _isCliEntry(): boolean {
|
|
1113
|
+
if (process.argv[1] === undefined) {
|
|
1114
|
+
return false;
|
|
1115
|
+
}
|
|
1116
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
1117
|
+
if (import.meta.url === argvUrl) {
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
// A symlinked invocation (e.g. via an installed `.augment/` projection,
|
|
1121
|
+
// or macOS /var → /private/var temp dirs) makes the raw URLs differ:
|
|
1122
|
+
// compare realpaths so the entry guard still fires.
|
|
1123
|
+
try {
|
|
1124
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1125
|
+
const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
|
|
1126
|
+
return here === argv1;
|
|
1127
|
+
} catch {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
1133
|
+
try {
|
|
1134
|
+
process.exit(main());
|
|
1135
|
+
} catch (exc) {
|
|
1136
|
+
const msg = exc instanceof Error ? exc.message : String(exc);
|
|
1137
|
+
process.stderr.write(`❌ Internal error: ${msg}\n`);
|
|
1138
|
+
process.exit(2);
|
|
1139
|
+
}
|
|
1140
|
+
}
|