@event4u/agent-config 14.13.0 → 14.14.1
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +138 -376
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +36 -0
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/code-comment-discipline.md +6 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
- package/dist/agent-src/scripts/stubs_due.ts +118 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +39 -2
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.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 +1696 -812
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +297 -115
- package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
- package/docs/guidelines/code-clarity.md +18 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/src/agent-src/scripts/guarded_baseline.ts +22 -7
- package/src/agent-src/scripts/stubs_due.ts +118 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/estate-count-budget.json +20 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +211 -1
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/preamble-payload-budget.json +19 -11
- package/src/config/rule-stub-ceilings.json +14 -7
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -4
- package/src/scripts/_lib/activation_ladder.ts +47 -3
- package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
- package/src/scripts/_lib/adr_frontmatter.ts +26 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
- package/src/scripts/_lib/carrier_status.ts +118 -0
- package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/corpus_manifest.ts +726 -0
- package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
- package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
- package/src/scripts/_lib/evaluation_cascade.ts +452 -0
- package/src/scripts/_lib/evolution_roi.ts +562 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/lexical_shortlist.ts +215 -0
- package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/pathology_archive.ts +393 -0
- package/src/scripts/_lib/promotion_capability.ts +315 -0
- package/src/scripts/_lib/promotion_evidence.ts +414 -0
- package/src/scripts/_lib/promotion_review.ts +294 -0
- package/src/scripts/_lib/prompt_shape.ts +92 -0
- package/src/scripts/_lib/release_highlights.ts +79 -1
- package/src/scripts/_lib/release_material.ts +125 -2
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/routing_corpus.ts +307 -0
- package/src/scripts/_lib/routing_index_input.ts +104 -0
- package/src/scripts/_lib/rule_injection.ts +22 -1
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/_lib/semantic_noop.ts +167 -0
- package/src/scripts/activation_receipt.ts +258 -0
- package/src/scripts/ai_council/cross_exam.ts +128 -0
- package/src/scripts/ai_council/explain_route.ts +157 -0
- package/src/scripts/ai_council/information_gain.ts +293 -0
- package/src/scripts/ai_council/leakage_corpus.ts +385 -0
- package/src/scripts/ai_council/leakage_patterns.ts +453 -0
- package/src/scripts/ai_council/minority_retention.ts +171 -0
- package/src/scripts/ai_council/recouncil_savings.ts +299 -0
- package/src/scripts/ai_council/replay_route.ts +223 -0
- package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
- package/src/scripts/ai_council/routing_training_row.ts +197 -0
- package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
- package/src/scripts/check_adr_frontmatter.ts +4 -9
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_estate_count.ts +205 -24
- package/src/scripts/check_release_highlights.ts +113 -14
- package/src/scripts/check_roadmap_trackable.ts +17 -2
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/corpus_manifest.ts +235 -0
- package/src/scripts/evolution_lab.ts +234 -9
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +28 -6
- package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/language_mirror_hook.ts +14 -2
- package/src/scripts/lint_carrier_integrity.ts +820 -0
- package/src/scripts/lint_code_comments.ts +748 -0
- package/src/scripts/lint_plan_risk_register.ts +39 -19
- package/src/scripts/lint_promotion_paths.ts +779 -0
- package/src/scripts/llm_propose.ts +256 -0
- package/src/scripts/measure_delivery_sets.ts +361 -0
- package/src/scripts/measure_routing_signal.ts +307 -0
- package/src/scripts/model_rule_injection.ts +19 -3
- package/src/scripts/probe_council_retention.ts +495 -0
- package/src/scripts/release.ts +21 -37
- package/src/scripts/release_drill.ts +199 -0
- package/src/scripts/release_env.ts +35 -0
- package/src/scripts/release_publication.ts +142 -0
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- package/src/scripts/sync_pr_branch.ts +521 -42
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lint_code_comments — the carrier `code-comment-discipline` and the
|
|
4
|
+
* "Code comments English" clause of `language-and-tone` never had.
|
|
5
|
+
*
|
|
6
|
+
* Both rules existed and neither could fire on the thing they govern.
|
|
7
|
+
* `language-and-tone` is a kernel rule and names one enforcer,
|
|
8
|
+
* `check_md_language.ts`, which rejects every path that is not `.md`
|
|
9
|
+
* (`check_md_language.ts:175`) — so the code clause carried an `enforced_by`
|
|
10
|
+
* entry that structurally cannot see a source file. `code-comment-discipline`
|
|
11
|
+
* is `type: auto` and matches PROMPT keywords, so a run that writes forty-five
|
|
12
|
+
* files without the user typing "comment", "refactor" or "implement" never
|
|
13
|
+
* loads it at all. Measured 2026-09-02 in a consumer repository: 40 of 45
|
|
14
|
+
* changed source files carried German comment lines, one of them a 101-line
|
|
15
|
+
* colour-analysis report inside a CSS file.
|
|
16
|
+
*
|
|
17
|
+
* Three classes, each keyed on STRUCTURE rather than on a judgement about
|
|
18
|
+
* prose, because a gate that has to decide whether a comment is "useful" is a
|
|
19
|
+
* gate nobody can predict:
|
|
20
|
+
*
|
|
21
|
+
* 1. `de-comment` — a comment line carrying German. Two independent
|
|
22
|
+
* signals: a German-specific character, or two or
|
|
23
|
+
* more German function words on one line.
|
|
24
|
+
* 2. `report-comment` — a comment block shaped like an analysis report.
|
|
25
|
+
* The five literal patterns are in REPORT_SIGNALS,
|
|
26
|
+
* named there rather than quoted here so this header
|
|
27
|
+
* does not trip its own gate. Length is deliberately
|
|
28
|
+
* NOT a signal; a long comment can be one long WHY.
|
|
29
|
+
* 3. `provenance-comment` — a comment explaining where the code came from:
|
|
30
|
+
* a roadmap path, a phase-and-step citation, a
|
|
31
|
+
* prototype or reference artefact path. Evidence
|
|
32
|
+
* belongs in the roadmap that asked for it, and a
|
|
33
|
+
* second copy in the source is the copy in the wrong
|
|
34
|
+
* place.
|
|
35
|
+
*
|
|
36
|
+
* Escape, per line or per file:
|
|
37
|
+
* code-comment-allow <class> -- <reason>
|
|
38
|
+
* A reason is mandatory; the bare marker does not suppress.
|
|
39
|
+
*
|
|
40
|
+
* Scope is the DIFF by default, so a pre-existing tree does not turn red.
|
|
41
|
+
* `--paths` and `--root` exist for auditing a tree the gate does not own.
|
|
42
|
+
*
|
|
43
|
+
* STATED RECALL LIMIT, found by running the gate against its own first output
|
|
44
|
+
* rather than by reasoning about it. A straight `"` left unpaired on a line is
|
|
45
|
+
* ambiguous — it opens a quotation that continues, or closes one that began
|
|
46
|
+
* above — and the gate reads it as an opener and stops scanning the rest of
|
|
47
|
+
* the line. That is right for an English sentence introducing a German quote
|
|
48
|
+
* and wrong for a German continuation whose opener sits on an earlier line.
|
|
49
|
+
* Typographic marks carry direction and do not have the ambiguity. The cost is
|
|
50
|
+
* a miss, never a false positive, and the answer is a second instrument: a
|
|
51
|
+
* plain word sweep over every changed file with no comment detection at all
|
|
52
|
+
* caught three lines this classifier could not, and both were run to zero
|
|
53
|
+
* before the tree was called clean. A gate that has been wrong once is not a
|
|
54
|
+
* gate to verify with alone.
|
|
55
|
+
*
|
|
56
|
+
* Exit codes: 0 clean · 2 findings · 1 usage or internal error.
|
|
57
|
+
*
|
|
58
|
+
* `--self-test` drives this binary over {@link COMMENT_CASES}, the one fixture
|
|
59
|
+
* table the vitest suite also enumerates. A gate that reports a `scanned:`
|
|
60
|
+
* count has proven it read something; only a case that must REJECT proves the
|
|
61
|
+
* reading changes the verdict, and only a shared table keeps the two corpora
|
|
62
|
+
* from drifting apart.
|
|
63
|
+
*/
|
|
64
|
+
import { spawnSync } from 'node:child_process';
|
|
65
|
+
import * as fs from 'node:fs';
|
|
66
|
+
import * as os from 'node:os';
|
|
67
|
+
import * as path from 'node:path';
|
|
68
|
+
import process from 'node:process';
|
|
69
|
+
import { fileURLToPath } from 'node:url';
|
|
70
|
+
|
|
71
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
72
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
73
|
+
|
|
74
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
75
|
+
|
|
76
|
+
/** Extensions whose comment syntax the line classifier understands. */
|
|
77
|
+
export const SCANNED_EXTENSIONS = new Set([
|
|
78
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
79
|
+
'.css', '.scss', '.less',
|
|
80
|
+
'.php', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb',
|
|
81
|
+
'.vue', '.svelte',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** Paths that are generated, vendored, or otherwise not authored here. */
|
|
85
|
+
const SKIP_SEGMENTS = ['node_modules/', 'dist/', 'build/', '.next/', 'vendor/', 'coverage/'];
|
|
86
|
+
|
|
87
|
+
/** Header markers that mark a whole file as machine-written. */
|
|
88
|
+
const GENERATED_MARKERS = [
|
|
89
|
+
'generated by', 'auto-generated', 'autogenerated', '@generated',
|
|
90
|
+
'do not edit', 'code generated by',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A comment line, in the languages above.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately line-shaped rather than a parser: a parser would be more
|
|
97
|
+
* precise about a comment token inside a string literal, and would also be a
|
|
98
|
+
* dependency and a second definition of "what is a comment" for every language
|
|
99
|
+
* added later. The cost of the approximation is a false positive on a string
|
|
100
|
+
* that begins a line with `//` or `*`, which the escape marker answers.
|
|
101
|
+
*/
|
|
102
|
+
const COMMENT_LINE = /^\s*(\{\s*)?(\/\/|\/\*|\*(?!\/)|\*\/|#(?!!)|--)/;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does this line OPEN a `/* … *\/` block that stays open past its own end?
|
|
106
|
+
*
|
|
107
|
+
* The missed case that made this necessary: a block comment whose continuation
|
|
108
|
+
* lines carry no leading `*` at all —
|
|
109
|
+
*
|
|
110
|
+
* /* Header line.
|
|
111
|
+
* Continuation nobody marked. *\/
|
|
112
|
+
*
|
|
113
|
+
* `COMMENT_LINE` matches the first line and nothing after it, so every
|
|
114
|
+
* continuation read as code and was never classified. Measured: a three-line
|
|
115
|
+
* German comment in a consumer repository survived a clean run of this gate,
|
|
116
|
+
* which is the failure that cost the gate its credibility on its first day.
|
|
117
|
+
*/
|
|
118
|
+
function opensBlock(line: string): boolean {
|
|
119
|
+
const lastOpen = line.lastIndexOf('/*');
|
|
120
|
+
if (lastOpen === -1) return false;
|
|
121
|
+
return line.indexOf('*/', lastOpen + 2) === -1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Does this line CLOSE an open block? */
|
|
125
|
+
function closesBlock(line: string): boolean {
|
|
126
|
+
return line.includes('*/');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** German-specific characters. `ß` and the umlauts appear in no English word. */
|
|
130
|
+
const DE_CHARS = /[äöüßÄÖÜ]/;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* German function words. Function words rather than nouns on purpose: a noun
|
|
134
|
+
* list is endless and collides with identifiers, while the closed class of
|
|
135
|
+
* articles, prepositions, conjunctions and auxiliaries is small, stable, and
|
|
136
|
+
* almost impossible to hit twice on one line of English.
|
|
137
|
+
*
|
|
138
|
+
* `die`, `man`, `hat`, `war`, `bei`, `im`, `am`, `so`, `ist` are deliberately
|
|
139
|
+
* ABSENT even though they are German: each is either an English word or a
|
|
140
|
+
* common identifier fragment, and the two-hit threshold is not enough
|
|
141
|
+
* protection when a single term is that ambiguous.
|
|
142
|
+
*/
|
|
143
|
+
const DE_WORDS = new RegExp(
|
|
144
|
+
'\\b(' + [
|
|
145
|
+
'der', 'das', 'dem', 'den', 'des',
|
|
146
|
+
'und', 'oder', 'aber', 'sondern', 'denn',
|
|
147
|
+
'nicht', 'kein', 'keine', 'keiner', 'keinen',
|
|
148
|
+
'wird', 'werden', 'wurde', 'worden', 'sind', 'waren',
|
|
149
|
+
'eine', 'einen', 'einem', 'einer', 'eines',
|
|
150
|
+
'dass', 'weil', 'damit', 'wenn', 'dann', 'sonst', 'obwohl',
|
|
151
|
+
'hier', 'dort', 'oben', 'unten', 'auch', 'nur', 'schon', 'noch',
|
|
152
|
+
'aus', 'fuer', 'ueber', 'durch', 'gegen', 'ohne', 'nach', 'vor',
|
|
153
|
+
'beim', 'zum', 'zur', 'vom', 'mit', 'sich', 'ihre', 'ihrer', 'seine',
|
|
154
|
+
'jede', 'jeder', 'jedes', 'alle', 'allen', 'aller',
|
|
155
|
+
'diese', 'dieser', 'dieses', 'diesem',
|
|
156
|
+
'steht', 'stehen', 'haben', 'kann', 'koennen', 'muss', 'muessen',
|
|
157
|
+
'soll', 'sollte', 'gibt', 'geht', 'macht', 'zeigt', 'liegt',
|
|
158
|
+
'deshalb', 'deswegen', 'trotzdem', 'bereits', 'immer', 'niemals',
|
|
159
|
+
'derselbe', 'dieselbe', 'dasselbe', 'denselben', 'demselben', 'derselben',
|
|
160
|
+
'andere', 'anderer', 'anderen', 'anderem', 'anderes',
|
|
161
|
+
'unter', 'ueber', 'zwischen', 'wegen', 'statt', 'ausser', 'seit',
|
|
162
|
+
'eigene', 'eigenen', 'eigener', 'jeweils', 'genau', 'etwa', 'sowohl',
|
|
163
|
+
'warum', 'wieso', 'welche', 'welcher', 'wobei', 'sowie',
|
|
164
|
+
].join('|') + ')\\b',
|
|
165
|
+
'gi',
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/** Structural report markers — a comment shaped like a document, not a note. */
|
|
169
|
+
const REPORT_SIGNALS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
170
|
+
['markdown-table', /^\s*(?:\/\/|\*|#|--)?\s*\|[^|]*\|[^|]*\|/],
|
|
171
|
+
['box-rule', /[─═━]{10,}/],
|
|
172
|
+
['section-mark', /^\s*(?:\/\/|\*|#|--)?\s*§\s+\S/],
|
|
173
|
+
['markdown-heading', /^\s*(?:\/\/|\*|#|--)?\s*#{2,}\s+\S/],
|
|
174
|
+
['revisit-if', /\brevisit-if\b/i],
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
/** Provenance markers — the code explaining its own paper trail. */
|
|
178
|
+
const PROVENANCE_SIGNALS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
179
|
+
['roadmap-path', /\bagents\/roadmaps?\//i],
|
|
180
|
+
['reference-path', /\bagents\/(references|evidence|decisions)\//i],
|
|
181
|
+
['phase-step', /\b(phase|phase)\s*\d+\s*,\s*(step|schritt)\s*\d+/i],
|
|
182
|
+
['prototype-source', /\b(prototyp\w*|prototype)\b[^\n]{0,40}\.(html|fig|sketch|dc\.html)\b/i],
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
export type FindingClass = 'de-comment' | 'report-comment' | 'provenance-comment';
|
|
186
|
+
|
|
187
|
+
export interface CommentFinding {
|
|
188
|
+
cls: FindingClass;
|
|
189
|
+
file: string;
|
|
190
|
+
line: number;
|
|
191
|
+
signal: string;
|
|
192
|
+
snippet: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** An escape marker with a real reason, for `cls` or for every class. */
|
|
196
|
+
function escapedFor(line: string, cls: FindingClass): boolean {
|
|
197
|
+
const m = /code-comment-allow\s+([a-z-]+|\*)\s*--\s*(\S.*)$/.exec(line);
|
|
198
|
+
if (!m) return false;
|
|
199
|
+
const target = m[1];
|
|
200
|
+
return target === '*' || target === cls;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Is the whole file exempt — a file-level marker, or a generator header? */
|
|
204
|
+
export function fileIsExempt(text: string): boolean {
|
|
205
|
+
const head = text.slice(0, 2000).toLowerCase();
|
|
206
|
+
if (GENERATED_MARKERS.some((m) => head.includes(m))) return true;
|
|
207
|
+
return /code-comment-allow-file\s*--\s*\S/.test(text.slice(0, 2000));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Distinct German function words in a fragment. */
|
|
211
|
+
function germanWordCount(s: string): number {
|
|
212
|
+
return new Set((s.match(DE_WORDS) ?? []).map((h) => h.toLowerCase())).size;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Strip the comment punctuation so a signal is matched against prose. */
|
|
216
|
+
function commentProse(line: string): string {
|
|
217
|
+
return line.replace(/^\s*(\/\/+|\/\*+|\*+\/?|#+|--)/, ' ');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Prose with code spans removed.
|
|
222
|
+
*
|
|
223
|
+
* The umlaut signal is otherwise wrong on an English docstring that quotes a
|
|
224
|
+
* non-ASCII sample value — `café@exämple.com` in a scrubber's own
|
|
225
|
+
* documentation is the measured case, and it is data being illustrated rather
|
|
226
|
+
* than German being written. Backtick spans and quoted literals are where
|
|
227
|
+
* that data lives, so both come out before the character test.
|
|
228
|
+
*/
|
|
229
|
+
function prosePlain(line: string): string {
|
|
230
|
+
return commentProse(line)
|
|
231
|
+
.replace(/`[^`]*`/g, ' ')
|
|
232
|
+
.replace(/'[^']*'/g, ' ')
|
|
233
|
+
.replace(/"[^"]*"/g, ' ')
|
|
234
|
+
.replace(/[\u201c\u201e][^\u201c\u201d\u201e]*[\u201d\u201c]/g, ' ')
|
|
235
|
+
.replace(/[\u2018\u201a][^\u2018\u2019\u201a]*[\u2019\u2018]/g, ' ')
|
|
236
|
+
.replace(/\u00bb[^\u00ab\u00bb]*\u00ab/g, ' ')
|
|
237
|
+
// Whatever follows the last unpaired TYPOGRAPHIC opener continues as a
|
|
238
|
+
// quotation on the next line, so it is quoted data too.
|
|
239
|
+
//
|
|
240
|
+
// A straight `"` is deliberately excluded here, and the exclusion is a
|
|
241
|
+
// repair: a leftover straight quote is just as likely to be the CLOSE
|
|
242
|
+
// of a quotation that began on the previous line, and treating it as an
|
|
243
|
+
// opener swallowed the rest of the line. Measured — a German
|
|
244
|
+
// continuation line reading `oben" heissen. Der Prototyp dreht sie …`
|
|
245
|
+
// scored zero function words because everything after the quote was
|
|
246
|
+
// erased. Typographic marks carry their own direction and do not have
|
|
247
|
+
// this ambiguity.
|
|
248
|
+
.replace(/[\u201c\u201e][^\u201c\u201d\u201e]*$/, ' ')
|
|
249
|
+
// A leftover STRAIGHT quote is ambiguous — it may open a quotation that
|
|
250
|
+
// continues on the next line, or close one that began on the previous.
|
|
251
|
+
// Trimming to end-of-line is right for the opener and wrong for the
|
|
252
|
+
// closer, so the trim is applied only when the text BEFORE it is not
|
|
253
|
+
// itself German: an English sentence introducing a German quotation is
|
|
254
|
+
// the opener case, and it is the one this must stay silent on.
|
|
255
|
+
.replace(/"[^"]*$/, (m, offset: number, whole: string) =>
|
|
256
|
+
germanWordCount(whole.slice(0, offset)) >= 2 ? m : ' ',
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Scan one file's text. Pure, so it answers for a buffer that is not on disk —
|
|
262
|
+
* which is what the write-time hook needs.
|
|
263
|
+
*/
|
|
264
|
+
export function scanText(file: string, text: string): CommentFinding[] {
|
|
265
|
+
if (fileIsExempt(text)) return [];
|
|
266
|
+
const out: CommentFinding[] = [];
|
|
267
|
+
const lines = text.split('\n');
|
|
268
|
+
// A quotation that spans lines is the normal way this tree records what a
|
|
269
|
+
// user actually said, and a per-line quote strip cannot see it: the
|
|
270
|
+
// opening mark is on one line and the closing mark two lines down, so
|
|
271
|
+
// every line between them reads as unquoted German. Carrying the open
|
|
272
|
+
// state across the block is what separates quoted DATA from German PROSE,
|
|
273
|
+
// and without it the six multi-line user quotations in this repository's
|
|
274
|
+
// own hooks are false positives.
|
|
275
|
+
let inQuote = false;
|
|
276
|
+
let inBlock = false;
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < lines.length; i++) {
|
|
279
|
+
const raw = lines[i] ?? '';
|
|
280
|
+
const isComment = inBlock || COMMENT_LINE.test(raw);
|
|
281
|
+
if (inBlock && closesBlock(raw)) inBlock = false;
|
|
282
|
+
else if (!inBlock && opensBlock(raw)) inBlock = true;
|
|
283
|
+
if (!isComment) { inQuote = false; continue; }
|
|
284
|
+
const prose = commentProse(raw);
|
|
285
|
+
const opensBefore = inQuote;
|
|
286
|
+
const marks = (commentProse(raw).match(/[\u201c\u201d\u201e"]/g) ?? []).length;
|
|
287
|
+
if (marks % 2 === 1) inQuote = !inQuote;
|
|
288
|
+
if (opensBefore) continue;
|
|
289
|
+
const at = i + 1;
|
|
290
|
+
const snip = raw.trim().slice(0, 100);
|
|
291
|
+
|
|
292
|
+
if (!escapedFor(raw, 'de-comment')) {
|
|
293
|
+
const plain = prosePlain(raw);
|
|
294
|
+
const distinct = new Set((plain.match(DE_WORDS) ?? []).map((h) => h.toLowerCase()));
|
|
295
|
+
const umlaut = DE_CHARS.test(plain);
|
|
296
|
+
// An umlaut ALONE proves nothing: `Robertson-Spärck-Jones`, a `Ü2`
|
|
297
|
+
// step label and a quoted sample value all carry one while the
|
|
298
|
+
// sentence around them is English. Two function words carry the
|
|
299
|
+
// sentence on their own; one is enough once a German character is
|
|
300
|
+
// also present. Measured over this repository's own source, both
|
|
301
|
+
// thresholds together leave zero findings on 402 files.
|
|
302
|
+
const isGerman = distinct.size >= 2 || (umlaut && distinct.size >= 1);
|
|
303
|
+
if (isGerman) {
|
|
304
|
+
out.push({
|
|
305
|
+
cls: 'de-comment', file, line: at,
|
|
306
|
+
signal: umlaut ? `umlaut+${[...distinct][0]}` : `function-words: ${[...distinct].slice(0, 4).join(' ')}`,
|
|
307
|
+
snippet: snip,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!escapedFor(raw, 'report-comment')) {
|
|
313
|
+
for (const [name, re] of REPORT_SIGNALS) {
|
|
314
|
+
if (re.test(raw)) {
|
|
315
|
+
out.push({ cls: 'report-comment', file, line: at, signal: name, snippet: snip });
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!escapedFor(raw, 'provenance-comment')) {
|
|
322
|
+
for (const [name, re] of PROVENANCE_SIGNALS) {
|
|
323
|
+
if (re.test(prose)) {
|
|
324
|
+
out.push({ cls: 'provenance-comment', file, line: at, signal: name, snippet: snip });
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Should this path be scanned at all? */
|
|
334
|
+
export function isScannable(rel: string): boolean {
|
|
335
|
+
if (SKIP_SEGMENTS.some((s) => rel.includes(s))) return false;
|
|
336
|
+
if (rel.endsWith('.d.ts')) return false;
|
|
337
|
+
return SCANNED_EXTENSIONS.has(path.extname(rel));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function changedFiles(root: string, base: string): string[] {
|
|
341
|
+
const r = spawnSync('git', ['diff', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`], {
|
|
342
|
+
cwd: root, encoding: 'utf-8',
|
|
343
|
+
});
|
|
344
|
+
const tracked = r.status === 0 ? r.stdout.split('\n') : [];
|
|
345
|
+
const u = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
346
|
+
cwd: root, encoding: 'utf-8',
|
|
347
|
+
});
|
|
348
|
+
const untracked = u.status === 0 ? u.stdout.split('\n') : [];
|
|
349
|
+
const m = spawnSync('git', ['diff', '--name-only', '--diff-filter=ACMR'], {
|
|
350
|
+
cwd: root, encoding: 'utf-8',
|
|
351
|
+
});
|
|
352
|
+
const dirty = m.status === 0 ? m.stdout.split('\n') : [];
|
|
353
|
+
return [...new Set([...tracked, ...untracked, ...dirty])].filter((f) => f.trim().length > 0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Line numbers this diff ADDED, per file. `'all'` for an untracked file, whose
|
|
358
|
+
* every line is new.
|
|
359
|
+
*
|
|
360
|
+
* The gate is described in `.github/workflows/rule-backstops.yml` as
|
|
361
|
+
* *"diff-scoped, so a pre-existing tree does not turn red"*, and selecting the
|
|
362
|
+
* FILE set from the diff while scanning each file WHOLE only delivers that for
|
|
363
|
+
* files nobody touches. Measured 2026-09-02 on PR #1813, which added no
|
|
364
|
+
* flagged comment at all and was refused on eleven legacy lines — four
|
|
365
|
+
* provenance paths and seven box rules — in four files it edited for unrelated
|
|
366
|
+
* reasons. A gate that reds on debt in any file you open makes every edit to an
|
|
367
|
+
* old file carry an unbounded comment sweep, and the escape it offers per line
|
|
368
|
+
* turns into exactly the allowlist growth `autonomous-execution` forbids.
|
|
369
|
+
*
|
|
370
|
+
* Teeth are unchanged where they belong: a comment the diff writes is scanned
|
|
371
|
+
* on the line it writes. The explicit-path mode (`--files`) stays whole-file,
|
|
372
|
+
* because a caller naming a path is asking for that file, not for a diff.
|
|
373
|
+
*
|
|
374
|
+
* Parsed from `--unified=0` hunk headers rather than from a patch body: the
|
|
375
|
+
* header carries the post-image start and count directly, so no line of added
|
|
376
|
+
* content has to be counted by hand.
|
|
377
|
+
*/
|
|
378
|
+
export function addedLineMap(root: string, base: string): Map<string, Set<number> | 'all'> {
|
|
379
|
+
const out = new Map<string, Set<number> | 'all'>();
|
|
380
|
+
const u = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
381
|
+
cwd: root, encoding: 'utf-8',
|
|
382
|
+
});
|
|
383
|
+
if (u.status === 0) {
|
|
384
|
+
for (const f of u.stdout.split('\n')) {
|
|
385
|
+
if (f.trim()) out.set(f.trim(), 'all');
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const argv of [
|
|
389
|
+
['diff', '--unified=0', '--diff-filter=ACMR', `${base}...HEAD`],
|
|
390
|
+
['diff', '--unified=0', '--diff-filter=ACMR'],
|
|
391
|
+
]) {
|
|
392
|
+
const r = spawnSync('git', argv, { cwd: root, encoding: 'utf-8' });
|
|
393
|
+
if (r.status !== 0) continue;
|
|
394
|
+
mergeAddedLines(out, parseAddedLines(r.stdout));
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Added line numbers per file, from a `git diff --unified=0` patch.
|
|
401
|
+
*
|
|
402
|
+
* Split out from the git call so the parse is testable without a repository
|
|
403
|
+
* whose diff the test would have to manufacture. A `@@` header with no count
|
|
404
|
+
* means one line — `@@ -4 +4 @@` — and a `+++ /dev/null` header is a deletion,
|
|
405
|
+
* which adds nothing.
|
|
406
|
+
*/
|
|
407
|
+
export function parseAddedLines(diffText: string): Map<string, Set<number>> {
|
|
408
|
+
const out = new Map<string, Set<number>>();
|
|
409
|
+
let file: string | null = null;
|
|
410
|
+
for (const line of diffText.split('\n')) {
|
|
411
|
+
if (line.startsWith('+++ ')) {
|
|
412
|
+
const p = line.slice(4).trim();
|
|
413
|
+
file = p === '/dev/null' ? null : p.replace(/^b\//, '');
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (!file || !line.startsWith('@@')) continue;
|
|
417
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
418
|
+
if (!m) continue;
|
|
419
|
+
const start = Number.parseInt(m[1] as string, 10);
|
|
420
|
+
const count = m[2] === undefined ? 1 : Number.parseInt(m[2], 10);
|
|
421
|
+
const set = out.get(file) ?? new Set<number>();
|
|
422
|
+
for (let n = start; n < start + count; n += 1) set.add(n);
|
|
423
|
+
out.set(file, set);
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Fold one parsed patch into the accumulator; `'all'` always wins. */
|
|
429
|
+
function mergeAddedLines(
|
|
430
|
+
into: Map<string, Set<number> | 'all'>,
|
|
431
|
+
from: Map<string, Set<number>>,
|
|
432
|
+
): void {
|
|
433
|
+
for (const [file, lines] of from) {
|
|
434
|
+
const prev = into.get(file);
|
|
435
|
+
if (prev === 'all') continue;
|
|
436
|
+
const set = prev ?? new Set<number>();
|
|
437
|
+
for (const n of lines) set.add(n);
|
|
438
|
+
into.set(file, set);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Findings the diff is answerable for: those sitting on a line it added.
|
|
444
|
+
*
|
|
445
|
+
* `'all'` (an untracked file) keeps everything, `undefined` (a file the diff
|
|
446
|
+
* did not touch) keeps nothing — the two ends of the same rule.
|
|
447
|
+
*/
|
|
448
|
+
export function findingsOnAddedLines(
|
|
449
|
+
findings: readonly CommentFinding[],
|
|
450
|
+
lines: Set<number> | 'all' | undefined,
|
|
451
|
+
): CommentFinding[] {
|
|
452
|
+
if (lines === undefined) return [];
|
|
453
|
+
if (lines === 'all') return [...findings];
|
|
454
|
+
return findings.filter((f) => lines.has(f.line));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** One fixture: a file body and the classes the gate must report on it. */
|
|
458
|
+
export interface CommentCase {
|
|
459
|
+
/** Printed by both consumers, so it reads as the assertion it makes. */
|
|
460
|
+
name: string;
|
|
461
|
+
/**
|
|
462
|
+
* The whole file body, written verbatim.
|
|
463
|
+
*
|
|
464
|
+
* Single-line literals with `\n` escapes rather than a template literal:
|
|
465
|
+
* a template whose lines began with a comment marker would be scanned as
|
|
466
|
+
* comments of THIS file, and the fixtures are deliberately violating.
|
|
467
|
+
*/
|
|
468
|
+
source: string;
|
|
469
|
+
/** Classes the classifier must report. Empty means the gate stays silent. */
|
|
470
|
+
flags: readonly FindingClass[];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The polarity corpus, exported because two callers must read the SAME one.
|
|
475
|
+
*
|
|
476
|
+
* `tests/scripts/lint_code_comments.test.ts` enumerates it against
|
|
477
|
+
* {@link scanText} and asserts the exact class set; `--self-test` enumerates it
|
|
478
|
+
* against the real binary and asserts the exit code. A second table would be a
|
|
479
|
+
* second definition of what this gate catches, and the one nobody runs locally
|
|
480
|
+
* is the one that drifts.
|
|
481
|
+
*
|
|
482
|
+
* The silent half is the load-bearing half. A language heuristic is only usable
|
|
483
|
+
* once its near-misses are pinned, and these are the shapes this repository's
|
|
484
|
+
* own source actually contains: an umlauted proper name, a non-ASCII sample
|
|
485
|
+
* value inside a code span, an umlauted step label, and a German user
|
|
486
|
+
* quotation spanning two comment lines.
|
|
487
|
+
*/
|
|
488
|
+
export const COMMENT_CASES: readonly CommentCase[] = [
|
|
489
|
+
{
|
|
490
|
+
name: 'a block-comment continuation carrying no leading star is flagged',
|
|
491
|
+
source:
|
|
492
|
+
' /* Hausform mit Objekt-`message`: derselbe Aufbau, anderer Schluessel.\n' +
|
|
493
|
+
' `BaseException::render()` dekodiert eine JSON-Zeichenkette und legt das\n' +
|
|
494
|
+
' Ergebnis unter `message` ab, bei Feldfehlern ist das ein Objekt. */\n' +
|
|
495
|
+
' if (isRecord(payload.message)) {\n',
|
|
496
|
+
flags: ['de-comment'],
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: 'a JSX comment, which opens with a brace rather than a slash, is flagged',
|
|
500
|
+
source: ' {/* Der Titel traegt die Aktion, nicht die Zeile. */}\n',
|
|
501
|
+
flags: ['de-comment'],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: 'a German comment line carrying two function words is flagged',
|
|
505
|
+
source: '// Hier stehen nur die Werte, die Bedeutung tragen\nconst a = 1;\n',
|
|
506
|
+
flags: ['de-comment'],
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: 'transliterated German with no umlaut at all is flagged',
|
|
510
|
+
source: '/* uebersetzt aus dem Prototyp, jeder Wert ist hier belegt */\n',
|
|
511
|
+
flags: ['de-comment'],
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: 'a German line whose only umlaut sits in ordinary prose is flagged',
|
|
515
|
+
source: '// Die Schriftstufen der kompakten Datenoberfläche\n',
|
|
516
|
+
flags: ['de-comment'],
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: 'an English comment quoting a non-ASCII sample value stays silent',
|
|
520
|
+
source: '/** so a `café@exämple.com` email or a `/Users/möchte/f` path masks */\n',
|
|
521
|
+
flags: [],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
name: 'an English comment carrying an umlauted proper name stays silent',
|
|
525
|
+
source: '/** Robertson–Spärck-Jones IDF, floored at 0 so terms never subtract. */\n',
|
|
526
|
+
flags: [],
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: 'an umlauted step label in English prose stays silent',
|
|
530
|
+
source: '// ── Ü2 — orthogonal stance assignment per seat ──\n',
|
|
531
|
+
flags: [],
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: 'a single-line German user quotation used as data stays silent',
|
|
535
|
+
source: '// the phrase "nicht releasen. Warum nicht?" names no fault\n',
|
|
536
|
+
flags: [],
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: 'a German user quotation spanning two comment lines stays silent',
|
|
540
|
+
source:
|
|
541
|
+
'/**\n * "Kein Council konfiguriert (keine `.agent-settings.yml`) — ich nutze\n' +
|
|
542
|
+
' * Subagenten-Fächer mit gegnerischen Linsen als Ersatz"\n */\n',
|
|
543
|
+
flags: [],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: 'plain English prose stays silent',
|
|
547
|
+
source: '// The cap is a stated default, not a measured optimum.\n',
|
|
548
|
+
flags: [],
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: 'a markdown table inside a comment is flagged',
|
|
552
|
+
source: ' * | Prototype | Role | here |\n',
|
|
553
|
+
flags: ['report-comment'],
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: 'a box-drawing rule is flagged',
|
|
557
|
+
source: ' * ─────────────────────────────\n',
|
|
558
|
+
flags: ['report-comment'],
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: 'a revisit clause is flagged',
|
|
562
|
+
source: ' * Revisit-if: someone holds the new surface next to the sidebar.\n',
|
|
563
|
+
flags: ['report-comment'],
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
name: 'a short dash rule that is not a report stays silent',
|
|
567
|
+
source: '// --- setup ---\n',
|
|
568
|
+
flags: [],
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: 'a roadmap citation is flagged',
|
|
572
|
+
source: '// see agents/roadmaps/todos-task-module-frontend.md\n',
|
|
573
|
+
flags: ['provenance-comment'],
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: 'a phase-and-step citation is flagged',
|
|
577
|
+
source: '// Roadmap todos-task-module, Phase 1, Schritt 1.\n',
|
|
578
|
+
flags: ['provenance-comment'],
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: 'a plain code reference stays silent',
|
|
582
|
+
source: '// see src/lib/token.ts for the parser\n',
|
|
583
|
+
flags: [],
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: 'a per-line escape carrying a reason suppresses the class',
|
|
587
|
+
source: '// Hier stehen die Werte code-comment-allow de-comment -- quoted spec text\n',
|
|
588
|
+
flags: [],
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: 'a bare escape with no reason suppresses nothing',
|
|
592
|
+
source: '// Hier stehen die Werte code-comment-allow de-comment\n',
|
|
593
|
+
flags: ['de-comment'],
|
|
594
|
+
},
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Drive the real binary over every fixture.
|
|
599
|
+
*
|
|
600
|
+
* Shells out rather than calling {@link scanText} in-process, because the
|
|
601
|
+
* thing a contributor runs is the CLI — its argv parsing, its scan-scope
|
|
602
|
+
* assertion and its exit codes included. The vitest suite covers the pure
|
|
603
|
+
* classifier; this covers the binary that ships.
|
|
604
|
+
*
|
|
605
|
+
* The floors are set just under the table's real size, so deleting a case to
|
|
606
|
+
* reach green fails instead, and any deliberate shrink shows up as a floor
|
|
607
|
+
* edit a reviewer reads in the diff.
|
|
608
|
+
*/
|
|
609
|
+
export function selfTest(): number {
|
|
610
|
+
const repoRoot = path.resolve(path.dirname(_HERE), '..', '..');
|
|
611
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lcc-selftest-'));
|
|
612
|
+
const exercise = (testCase: CommentCase, index: number): number => {
|
|
613
|
+
const file = path.join(dir, `case-${String(index)}.ts`);
|
|
614
|
+
fs.writeFileSync(file, testCase.source, 'utf-8');
|
|
615
|
+
return runGateCli(repoRoot, 'src/scripts/lint_code_comments.ts', ['--paths', file], dir);
|
|
616
|
+
};
|
|
617
|
+
try {
|
|
618
|
+
return runSelfTest({
|
|
619
|
+
gate: 'lint_code_comments',
|
|
620
|
+
minCases: 16,
|
|
621
|
+
minRejectCases: 8,
|
|
622
|
+
cases: COMMENT_CASES.map((testCase, index) => ({
|
|
623
|
+
name: testCase.name,
|
|
624
|
+
expect: testCase.flags.length > 0 ? ('reject' as const) : ('accept' as const),
|
|
625
|
+
run: () => exercise(testCase, index),
|
|
626
|
+
})),
|
|
627
|
+
});
|
|
628
|
+
} finally {
|
|
629
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const HELP = `usage: lint_code_comments [--base <ref>] [--root <dir>] [--paths <f> …] [--report]
|
|
634
|
+
|
|
635
|
+
--base <ref> diff against this ref (default: origin/main)
|
|
636
|
+
--root <dir> run against another checkout (default: this repository)
|
|
637
|
+
--paths scan exactly these files, ignoring the diff
|
|
638
|
+
--report print every finding grouped by file and exit 0
|
|
639
|
+
--self-test drive this binary over its own fixture table and exit 0 on pass
|
|
640
|
+
|
|
641
|
+
Classes: de-comment · report-comment · provenance-comment
|
|
642
|
+
Escape: code-comment-allow <class|*> -- <reason> (per line)
|
|
643
|
+
code-comment-allow-file -- <reason> (first 2000 chars)
|
|
644
|
+
`;
|
|
645
|
+
|
|
646
|
+
export function main(argv: string[]): number {
|
|
647
|
+
// `--help` wins, and the recursion guard is not decoration: `runGateCli`
|
|
648
|
+
// sets GATE_SELF_TEST_CHILD precisely so a child cannot re-enter the suite.
|
|
649
|
+
// Latent today because the child's argv is `--paths <file>`, but any later
|
|
650
|
+
// change that forwards the parent's argv would recurse 18-way per level.
|
|
651
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
652
|
+
process.stdout.write(HELP);
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
if (argv.includes('--self-test')) {
|
|
656
|
+
if (process.env['GATE_SELF_TEST_CHILD'] === '1') {
|
|
657
|
+
process.stderr.write('lint_code_comments: refusing to nest --self-test inside a self-test child\n');
|
|
658
|
+
return 2;
|
|
659
|
+
}
|
|
660
|
+
return selfTest();
|
|
661
|
+
}
|
|
662
|
+
let base = 'origin/main';
|
|
663
|
+
let root = path.resolve(path.dirname(_HERE), '..', '..');
|
|
664
|
+
let reportOnly = false;
|
|
665
|
+
const explicit: string[] = [];
|
|
666
|
+
|
|
667
|
+
for (let i = 0; i < argv.length; i++) {
|
|
668
|
+
const a = argv[i];
|
|
669
|
+
if (a === '--help' || a === '-h') { process.stdout.write(HELP); return 0; }
|
|
670
|
+
else if (a === '--base') { base = argv[++i] ?? ''; }
|
|
671
|
+
else if (a === '--root') { root = path.resolve(argv[++i] ?? '.'); }
|
|
672
|
+
else if (a === '--report') { reportOnly = true; }
|
|
673
|
+
else if (a === '--paths') { while (i + 1 < argv.length && !argv[i + 1]!.startsWith('--')) explicit.push(argv[++i]!); }
|
|
674
|
+
else { process.stderr.write(`lint_code_comments: unknown argument ${a}\n${HELP}`); return 1; }
|
|
675
|
+
}
|
|
676
|
+
if (!base && explicit.length === 0) { process.stderr.write('--base needs a ref\n'); return 1; }
|
|
677
|
+
|
|
678
|
+
const candidates = (explicit.length > 0 ? explicit : changedFiles(root, base)).filter(isScannable);
|
|
679
|
+
// Whole file when a caller named the path; only the lines the diff added
|
|
680
|
+
// when the scope IS the diff — see `addedLineMap` for the measured reason.
|
|
681
|
+
const added = explicit.length > 0 ? null : addedLineMap(root, base);
|
|
682
|
+
const findings: CommentFinding[] = [];
|
|
683
|
+
for (const rel of candidates) {
|
|
684
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
|
685
|
+
let text: string;
|
|
686
|
+
try { text = fs.readFileSync(abs, 'utf-8'); } catch { continue; }
|
|
687
|
+
const all = scanText(rel, text);
|
|
688
|
+
findings.push(...(added ? findingsOnAddedLines(all, added.get(rel)) : all));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
reportScanned({
|
|
693
|
+
gate: 'lint_code_comments',
|
|
694
|
+
scanned: candidates.length,
|
|
695
|
+
units: 'source file(s)',
|
|
696
|
+
roots: explicit.length > 0 ? explicit.slice(0, 4) : [`git diff ${base}...HEAD`],
|
|
697
|
+
allowEmpty:
|
|
698
|
+
'EMPTY_VALID: the scan root is a DIFF, and a diff with no source file in it is the ' +
|
|
699
|
+
'normal case for a docs-only or config-only change. git already answered for the ' +
|
|
700
|
+
'range: an unreadable ref yields no candidates AND no findings, which is ' +
|
|
701
|
+
'indistinguishable here from a clean docs commit, so this gate deliberately does ' +
|
|
702
|
+
'not treat zero as a dead scope. The scope failure this class exists to catch — a ' +
|
|
703
|
+
'moved source tree — cannot occur, because there is no committed path list to go ' +
|
|
704
|
+
'stale.',
|
|
705
|
+
});
|
|
706
|
+
} catch (e) {
|
|
707
|
+
if (e instanceof DeadScopeError) {
|
|
708
|
+
process.stderr.write(`❌ ${e.message}\n`);
|
|
709
|
+
return 1;
|
|
710
|
+
}
|
|
711
|
+
throw e;
|
|
712
|
+
}
|
|
713
|
+
process.stdout.write(` against ${explicit.length > 0 ? 'an explicit path list' : base}\n`);
|
|
714
|
+
if (findings.length === 0) {
|
|
715
|
+
process.stdout.write('✅ lint_code_comments: no German, report-shaped or provenance comments.\n');
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const byFile = new Map<string, CommentFinding[]>();
|
|
720
|
+
for (const f of findings) { const l = byFile.get(f.file) ?? []; l.push(f); byFile.set(f.file, l); }
|
|
721
|
+
for (const [file, list] of [...byFile].sort()) {
|
|
722
|
+
process.stdout.write(`\n${file} (${list.length})\n`);
|
|
723
|
+
for (const f of list.slice(0, 8)) {
|
|
724
|
+
process.stdout.write(` ${String(f.line).padStart(5)} ${f.cls}/${f.signal} ${f.snippet}\n`);
|
|
725
|
+
}
|
|
726
|
+
if (list.length > 8) process.stdout.write(` … ${list.length - 8} more in this file\n`);
|
|
727
|
+
}
|
|
728
|
+
const counts = new Map<string, number>();
|
|
729
|
+
for (const f of findings) counts.set(f.cls, (counts.get(f.cls) ?? 0) + 1);
|
|
730
|
+
process.stdout.write(`\n${[...counts].map(([c, n]) => `${c}: ${n}`).join(' · ')} across ${byFile.size} file(s)\n`);
|
|
731
|
+
|
|
732
|
+
if (reportOnly) return 0;
|
|
733
|
+
process.stderr.write(
|
|
734
|
+
'\n❌ lint_code_comments: comments carry language, evidence or report structure that does not belong in source.\n' +
|
|
735
|
+
' A comment states a WHY or a constraint the code cannot show, in English. Evidence belongs in the\n' +
|
|
736
|
+
' roadmap that asked for it. Rewrite the comment, delete it, or escape one line with\n' +
|
|
737
|
+
' `code-comment-allow <class> -- <reason>`.\n',
|
|
738
|
+
);
|
|
739
|
+
return 2;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const _isMain = (() => {
|
|
743
|
+
const entry = process.argv[1];
|
|
744
|
+
if (!entry) return false;
|
|
745
|
+
try { return fs.realpathSync(entry) === fs.realpathSync(_HERE); } catch { return false; }
|
|
746
|
+
})();
|
|
747
|
+
|
|
748
|
+
if (_isMain) process.exit(main(process.argv.slice(2)));
|