@event4u/agent-config 14.18.0 → 14.20.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +120 -129
- package/README.md +71 -149
- package/dist/agent-src/README.md +1 -1
- package/dist/agent-src/commands/agent-handoff.md +44 -43
- package/dist/agent-src/commands/review/changes.md +19 -19
- package/dist/agent-src/commands/review.md +2 -2
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +37 -4
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +2 -2
- package/dist/agent-src/rules/guidelines.md +1 -1
- package/dist/agent-src/rules/session-canary.md +2 -2
- package/dist/agent-src/rules/token-efficiency.md +1 -1
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
- package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
- package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
- package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
- package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
- package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
- package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
- package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
- package/dist/agent-src/skills/design-review/SKILL.md +1 -1
- package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
- package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
- package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
- package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +17 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +14 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/commands/recordTriggerEval.js +2 -2
- package/dist/cli/main.js +1 -1
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-C6IPZRDA.js} +484 -353
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-NUMQHLS5.js} +304 -136
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_adr_effective.js +318 -81
- package/dist/cli-delegate/cmd_conformance.js +489 -121
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +42 -12
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +34 -33
- 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 +4455 -3025
- package/dist/install/install.mjs +975 -689
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +2087 -1134
- package/dist/scripts/_lib/model_tier.js +1 -1
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/design-antipatterns.md +87 -64
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/package.json +1 -1
- package/src/agent-src/README.md +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/ci-local-parity.yml +6 -0
- package/src/config/gate-coverage.yml +225 -0
- package/src/config/gate-violation-baselines.json +18 -6
- package/src/config/release-gate-locality.yml +12 -0
- package/src/scripts/_cli/cmd_doctor.ts +2 -2
- package/src/scripts/_cli/cmd_handoff.ts +1 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_cli/cmd_update.ts +1 -1
- package/src/scripts/_cli/cmd_versions.ts +1 -1
- package/src/scripts/_lib/adr_frontmatter.ts +44 -0
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/consequence_objects.ts +152 -0
- package/src/scripts/_lib/exec_evidence.ts +7 -0
- package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
- package/src/scripts/_lib/host_capability.ts +38 -0
- package/src/scripts/_lib/host_permission_checks.ts +251 -0
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +1 -1
- package/src/scripts/_lib/obligation_frequency.ts +2 -2
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_highlights.ts +93 -0
- package/src/scripts/_lib/release_material.ts +64 -1
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/runtime_journal.ts +26 -3
- package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/session_register.ts +11 -6
- package/src/scripts/_lib/sqlite_schema.ts +22 -0
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/_lib/surface.ts +86 -0
- package/src/scripts/_lib/ui_authority.ts +116 -0
- package/src/scripts/adr/regenerate_index.ts +54 -1
- package/src/scripts/adr_cite_check.ts +71 -15
- package/src/scripts/ai_council/clients.ts +1 -1
- package/src/scripts/autonomy_friction_corpus.ts +229 -0
- package/src/scripts/build_proof.ts +15 -2
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_beta_review_markers.ts +141 -17
- package/src/scripts/check_enforcement_coverage.ts +1 -1
- package/src/scripts/check_enforcement_denominator.ts +121 -11
- package/src/scripts/check_finding_dispositions.ts +66 -9
- package/src/scripts/check_gate_coverage.ts +96 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/check_release_highlights.ts +74 -1
- package/src/scripts/check_skill_activation_claim.ts +179 -0
- package/src/scripts/check_trigger_evals.ts +1 -1
- package/src/scripts/conformance_scan.ts +44 -3
- package/src/scripts/explain_run.ts +1 -1
- package/src/scripts/generate_capability_matrix.ts +1 -1
- package/src/scripts/git_authorization_hook.ts +75 -1
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +14 -11
- package/src/scripts/hooks/augment-dispatcher.sh +1 -1
- package/src/scripts/hooks/category_a.ts +291 -0
- package/src/scripts/hooks/cline-dispatcher.sh +1 -1
- package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
- package/src/scripts/hooks/dispatch_hook.ts +17 -22
- package/src/scripts/hooks/git_command_classifier.ts +156 -1
- package/src/scripts/hooks/host_lowering.ts +215 -0
- package/src/scripts/hooks/host_lowering.yaml +185 -0
- package/src/scripts/hooks/host_semantics.ts +105 -37
- package/src/scripts/hooks/journal_record_hook.ts +10 -0
- package/src/scripts/hooks/permission_gate.ts +117 -0
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/hooks_status.ts +12 -10
- package/src/scripts/install.ts +57 -86
- package/src/scripts/install_anthropic_key.sh +2 -2
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_design_antipattern_parity.ts +80 -0
- package/src/scripts/lint_evidence_artifacts.ts +45 -1
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_hook_manifest.ts +87 -3
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_motion_authority_drift.ts +731 -0
- package/src/scripts/lint_readme_size.ts +158 -12
- package/src/scripts/lint_reasoning_fixtures.ts +216 -0
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/measure_sibling_naming.ts +150 -0
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/redact_hook_capture.ts +1 -1
- package/src/scripts/release.ts +24 -24
- package/src/scripts/release_publication.ts +6 -2
- package/src/scripts/report_skill_activation.ts +319 -7
- package/src/scripts/schemas/review-findings.schema.json +5 -0
- package/src/scripts/schemas/skill.schema.json +2 -2
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/session_canary_hook.ts +1 -1
- package/src/scripts/session_register_hook.ts +4 -3
- package/src/scripts/skill_scout.ts +69 -0
- package/src/scripts/trigger_eval_grandfather.json +5 -1
- package/src/scripts/validate_agent_settings.ts +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
|
@@ -39,9 +39,26 @@
|
|
|
39
39
|
* Escape hatch: `<!-- enforcement-count-ok: <reason> -->` on the line, reason
|
|
40
40
|
* mandatory. A bare marker stays a finding.
|
|
41
41
|
*
|
|
42
|
+
* THE SECOND HALF — the projection owes the split. Exempting the generated file
|
|
43
|
+
* settles WHERE the number may be published; it says nothing about whether the
|
|
44
|
+
* published number is readable. `undeclared` quoted bare reads as a backlog
|
|
45
|
+
* somebody forgot to wire, and for nine of them that is not what it is:
|
|
46
|
+
* `block_kernel_rule_writes` refuses an `enforced_by` write on a kernel rule
|
|
47
|
+
* with no agent-accessible override, so those cannot declare one at all. The
|
|
48
|
+
* resolver has always computed `kernel_denied`; the published line dropped it
|
|
49
|
+
* and appended the word `yet`, promising a future that does not exist for that
|
|
50
|
+
* nine. So a line in the sanctioned projection that states an `undeclared`
|
|
51
|
+
* figure without its kernel-denied split is the SAME class of failure as a
|
|
52
|
+
* hand-written count elsewhere: a figure the reader cannot resolve.
|
|
53
|
+
*
|
|
54
|
+
* This deliberately does NOT check the split's value, for the same reason the
|
|
55
|
+
* first half does not check the count's — the resolver is the only source, and
|
|
56
|
+
* a value comparison here would be a second place the number lives.
|
|
57
|
+
*
|
|
42
58
|
* Exit codes:
|
|
43
|
-
* 0 — no hand-written enforcement count in the published corpus
|
|
44
|
-
*
|
|
59
|
+
* 0 — no hand-written enforcement count in the published corpus, and every
|
|
60
|
+
* published `undeclared` figure carries its kernel-denied split
|
|
61
|
+
* 1 — at least one (or a marker with no reason, or a split-less figure)
|
|
45
62
|
* 2 — the gate could not run (dead scan scope, bad args)
|
|
46
63
|
*/
|
|
47
64
|
import * as fs from 'node:fs';
|
|
@@ -57,8 +74,8 @@ const _HERE = fileURLToPath(import.meta.url);
|
|
|
57
74
|
const DEFAULT_ROOT = path.join(path.dirname(_HERE), '..', '..');
|
|
58
75
|
const SELF = 'src/scripts/check_enforcement_denominator.ts';
|
|
59
76
|
|
|
60
|
-
const SELF_TEST_MIN_CASES =
|
|
61
|
-
const SELF_TEST_MIN_REJECT =
|
|
77
|
+
const SELF_TEST_MIN_CASES = 10;
|
|
78
|
+
const SELF_TEST_MIN_REJECT = 5;
|
|
62
79
|
|
|
63
80
|
/** Roots walked, repo-relative. */
|
|
64
81
|
const SCAN_ROOTS = ['docs', 'README.md'] as const;
|
|
@@ -82,6 +99,19 @@ const MARKER = 'enforcement-count-ok:';
|
|
|
82
99
|
const ENFORCEMENT_KEYWORDS =
|
|
83
100
|
/undeclared|enforced_by|enforcement coverage|carry a backstop|backstop that fails/;
|
|
84
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Shapes that state the `undeclared` figure itself. Narrower than
|
|
104
|
+
* {@link COUNT_SHAPES} on purpose: this is the ONE figure whose split is
|
|
105
|
+
* load-bearing, and a wider net would demand the split beside `blocking`.
|
|
106
|
+
*/
|
|
107
|
+
const UNDECLARED_FIGURE_SHAPES: readonly RegExp[] = [
|
|
108
|
+
/\b\d+\s+undeclared\b/i,
|
|
109
|
+
/\bundeclared\s+rules?\s*\(\s*\d+/i,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** The split that makes the figure readable. Presence is checked, never value. */
|
|
113
|
+
const SPLIT_TOKEN = /kernel[-_]denied/i;
|
|
114
|
+
|
|
85
115
|
const COUNT_SHAPES: readonly RegExp[] = [
|
|
86
116
|
/\b\d+\s+of\s+\d+\s+(?:governed\s+)?rules?\b/,
|
|
87
117
|
/\b\d+\s+(?:governed\s+)?rules?\b/,
|
|
@@ -93,8 +123,12 @@ export interface Finding {
|
|
|
93
123
|
file: string;
|
|
94
124
|
line: number;
|
|
95
125
|
excerpt: string;
|
|
96
|
-
/**
|
|
97
|
-
|
|
126
|
+
/**
|
|
127
|
+
* `raw` — an unmarked count. `bare-marker` — a marker with no reason.
|
|
128
|
+
* `missing-split` — the projection states `undeclared` without its
|
|
129
|
+
* kernel-denied split.
|
|
130
|
+
*/
|
|
131
|
+
kind: 'raw' | 'bare-marker' | 'missing-split';
|
|
98
132
|
}
|
|
99
133
|
|
|
100
134
|
function markerOn(line: string): 'none' | 'bare' | 'reasoned' {
|
|
@@ -123,6 +157,30 @@ export function scanText(relPath: string, text: string): Finding[] {
|
|
|
123
157
|
return out;
|
|
124
158
|
}
|
|
125
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The projection's own obligation: every line stating an `undeclared` figure
|
|
162
|
+
* carries the kernel-denied split. Run INSTEAD of {@link scanText} on a
|
|
163
|
+
* generated file — that file is allowed to state the number, and is the only
|
|
164
|
+
* one that owes the split.
|
|
165
|
+
*/
|
|
166
|
+
export function scanGenerated(relPath: string, text: string): Finding[] {
|
|
167
|
+
const out: Finding[] = [];
|
|
168
|
+
const lines = text.split('\n');
|
|
169
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
170
|
+
const line = lines[i] ?? '';
|
|
171
|
+
if (!UNDECLARED_FIGURE_SHAPES.some((re) => re.test(line))) continue;
|
|
172
|
+
if (SPLIT_TOKEN.test(line)) continue;
|
|
173
|
+
if (markerOn(line) === 'reasoned') continue;
|
|
174
|
+
out.push({
|
|
175
|
+
file: relPath,
|
|
176
|
+
line: i + 1,
|
|
177
|
+
excerpt: line.trim().slice(0, 120),
|
|
178
|
+
kind: 'missing-split',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
126
184
|
/** `true` when the file declares itself generated — i.e. it IS the projection. */
|
|
127
185
|
export function isGenerated(text: string): boolean {
|
|
128
186
|
return text.split('\n', GENERATED_HEAD_LINES).join('\n').includes(GENERATED_MARKER);
|
|
@@ -177,7 +235,13 @@ export function check(root: string): { code: number; findings: Finding[]; scanne
|
|
|
177
235
|
for (const rel of files) {
|
|
178
236
|
const text = fs.readFileSync(path.join(root, rel), 'utf-8');
|
|
179
237
|
if (isGenerated(text)) {
|
|
180
|
-
|
|
238
|
+
const split = scanGenerated(rel, text);
|
|
239
|
+
if (split.length > 0) {
|
|
240
|
+
findings.push(...split);
|
|
241
|
+
ledger.fail(rel, `${String(split.length)} undeclared figure(s) without the split`);
|
|
242
|
+
} else {
|
|
243
|
+
ledger.complete(rel);
|
|
244
|
+
}
|
|
181
245
|
continue;
|
|
182
246
|
}
|
|
183
247
|
const found = scanText(rel, text);
|
|
@@ -259,8 +323,21 @@ export function selfTest(): number {
|
|
|
259
323
|
}),
|
|
260
324
|
},
|
|
261
325
|
{
|
|
262
|
-
name: "accepts the resolver's own generated projection",
|
|
326
|
+
name: "accepts the resolver's own generated projection when it carries the split",
|
|
263
327
|
expect: 'accept',
|
|
328
|
+
run: () =>
|
|
329
|
+
run({
|
|
330
|
+
'docs/proof.md':
|
|
331
|
+
`<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
|
|
332
|
+
'120 rules · 15 blocking · 86 undeclared — of which 9 kernel-denied.\n',
|
|
333
|
+
}),
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
// DENIAL POLARITY for the split half. Same file, same exemption, the
|
|
337
|
+
// split removed: the projection is allowed to state the number and is
|
|
338
|
+
// still a finding when it states it bare.
|
|
339
|
+
name: 'rejects the generated projection when the kernel-denied split is removed',
|
|
340
|
+
expect: 'reject',
|
|
264
341
|
run: () =>
|
|
265
342
|
run({
|
|
266
343
|
'docs/proof.md':
|
|
@@ -268,6 +345,26 @@ export function selfTest(): number {
|
|
|
268
345
|
'120 rules · 15 blocking · 86 undeclared (no enforced_by yet).\n',
|
|
269
346
|
}),
|
|
270
347
|
},
|
|
348
|
+
{
|
|
349
|
+
name: 'rejects a bare "Undeclared rules (N)" restatement in the projection',
|
|
350
|
+
expect: 'reject',
|
|
351
|
+
run: () =>
|
|
352
|
+
run({
|
|
353
|
+
'docs/proof.md':
|
|
354
|
+
`<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
|
|
355
|
+
'Undeclared rules (86) carry no row.\n',
|
|
356
|
+
}),
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: 'accepts a generated line about enforcement that states no undeclared figure',
|
|
360
|
+
expect: 'accept',
|
|
361
|
+
run: () =>
|
|
362
|
+
run({
|
|
363
|
+
'docs/proof.md':
|
|
364
|
+
`<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
|
|
365
|
+
'120 rules · 15 blocking · the resolver prints its own frame.\n',
|
|
366
|
+
}),
|
|
367
|
+
},
|
|
271
368
|
{
|
|
272
369
|
name: 'accepts a dated ADR record (docs/decisions/ is excluded)',
|
|
273
370
|
expect: 'accept',
|
|
@@ -318,12 +415,25 @@ export function main(argv?: readonly string[]): number {
|
|
|
318
415
|
if (code === 2) return 2;
|
|
319
416
|
|
|
320
417
|
if (findings.length > 0) {
|
|
321
|
-
process.stdout.write('❌
|
|
418
|
+
process.stdout.write('❌ unresolvable enforcement figure(s) in a published doc:\n\n');
|
|
322
419
|
for (const f of findings) {
|
|
323
420
|
const why =
|
|
324
|
-
f.kind === 'bare-marker'
|
|
421
|
+
f.kind === 'bare-marker'
|
|
422
|
+
? `\`${MARKER}\` with no reason`
|
|
423
|
+
: f.kind === 'missing-split'
|
|
424
|
+
? 'published `undeclared` figure with no kernel-denied split'
|
|
425
|
+
: 'hand-written count';
|
|
325
426
|
process.stdout.write(` ${f.file}:${String(f.line)} — ${why}\n ${f.excerpt}\n`);
|
|
326
427
|
}
|
|
428
|
+
if (findings.some((f) => f.kind === 'missing-split')) {
|
|
429
|
+
process.stdout.write(
|
|
430
|
+
'\nThe projection may state the `undeclared` figure — it is the only surface\n' +
|
|
431
|
+
'that may — but not bare. Nine of those rules are kernel-denied:\n' +
|
|
432
|
+
'`block_kernel_rule_writes` refuses an `enforced_by` write with no\n' +
|
|
433
|
+
'agent-accessible override, so for them there is no *yet*. Print\n' +
|
|
434
|
+
"`summary.kernel_denied` on the same line (`build_proof.ts` § 4b does).\n",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
327
437
|
process.stdout.write(
|
|
328
438
|
'\nExactly one enforcement denominator is quotable, and it comes from\n' +
|
|
329
439
|
'`check_enforcement_coverage` — which resolves rather than counts, and prints\n' +
|
|
@@ -362,4 +472,4 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
|
362
472
|
process.exit(main());
|
|
363
473
|
}
|
|
364
474
|
|
|
365
|
-
export { SCAN_ROOTS, EXCLUDED_PREFIXES, MARKER };
|
|
475
|
+
export { SCAN_ROOTS, EXCLUDED_PREFIXES, MARKER, SPLIT_TOKEN, UNDECLARED_FIGURE_SHAPES };
|
|
@@ -50,12 +50,22 @@
|
|
|
50
50
|
* not override either, so in CI the local tag list is EMPTY. See releaseStatus
|
|
51
51
|
* for the asymmetry and the remote fallback.
|
|
52
52
|
*
|
|
53
|
+
* AN EMPTY LEDGER MUST SAY WHY IT IS EMPTY. The released/unreleased split above
|
|
54
|
+
* closed the missing-file half; it left the other half open, because
|
|
55
|
+
* `findings: []` still passed while asserting nothing. A writer could satisfy
|
|
56
|
+
* the gate by creating an empty file, which is the same silence wearing the
|
|
57
|
+
* shape of a record. `no_findings_reason` is now required whenever the file
|
|
58
|
+
* exists and the array is empty — see empty_ledger_problem for what the reason
|
|
59
|
+
* has to distinguish.
|
|
60
|
+
*
|
|
53
61
|
* A future reader changing allowEmpty below is the person this is addressed to:
|
|
54
|
-
* the EMPTY_VALID waiver
|
|
55
|
-
* unreleased version. It
|
|
62
|
+
* the EMPTY_VALID waiver now covers only an EXPLAINED empty ledger and an
|
|
63
|
+
* unreleased version. It covers neither a released version with no ledger nor an
|
|
64
|
+
* empty ledger with no reason.
|
|
56
65
|
*
|
|
57
66
|
* Exit codes: 0 complete · 1 missing/incomplete dispositions (an absent ledger
|
|
58
|
-
* for a RELEASED version
|
|
67
|
+
* for a RELEASED version and an unexplained empty ledger are both among them) ·
|
|
68
|
+
* 2 usage error, schema error, or an
|
|
59
69
|
* undeterminable release status. Deliberately NOT fail-open: this script never calls a model;
|
|
60
70
|
* a broken ledger file is a hard error, not a neutral skip.
|
|
61
71
|
*/
|
|
@@ -65,7 +75,7 @@ import * as path from 'node:path';
|
|
|
65
75
|
import process from 'node:process';
|
|
66
76
|
import { fileURLToPath } from 'node:url';
|
|
67
77
|
|
|
68
|
-
import {
|
|
78
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
69
79
|
|
|
70
80
|
const _HERE = fileURLToPath(import.meta.url);
|
|
71
81
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -91,6 +101,8 @@ export interface Ledger {
|
|
|
91
101
|
schema_version: number;
|
|
92
102
|
release: string;
|
|
93
103
|
findings: LedgerFinding[];
|
|
104
|
+
/** Why the finding set is empty. Required when it is; see empty_ledger_problem. */
|
|
105
|
+
no_findings_reason?: string;
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
/** Mirrors self_review_gate.classifyBlocking — security/claim × critical/high. */
|
|
@@ -136,6 +148,36 @@ export function missing_dispositions(findings: readonly LedgerFinding[]): string
|
|
|
136
148
|
return problems;
|
|
137
149
|
}
|
|
138
150
|
|
|
151
|
+
/**
|
|
152
|
+
* An empty finding set must say why it is empty.
|
|
153
|
+
*
|
|
154
|
+
* `findings: []` and a missing file made the same claim to every reader — "nothing
|
|
155
|
+
* to disposition" — and only one of them is evidence. Worse, they collapse two
|
|
156
|
+
* states that differ entirely: a review that ran and reported nothing, and a review
|
|
157
|
+
* that never ran at all. The reason field is what forces the writer to pick one and
|
|
158
|
+
* makes the pick falsifiable, so a fabricated empty ledger has to assert something
|
|
159
|
+
* a reader can check rather than merely occupy the path the gate looks at.
|
|
160
|
+
*
|
|
161
|
+
* Applies ONLY to a ledger file that exists. An absent ledger on an unreleased
|
|
162
|
+
* version is the normal in-flight state and has nothing to carry a reason.
|
|
163
|
+
*/
|
|
164
|
+
export function empty_ledger_problem(
|
|
165
|
+
ledger: Pick<Ledger, 'findings' | 'no_findings_reason'>,
|
|
166
|
+
): string | null {
|
|
167
|
+
if (ledger.findings.length > 0) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if ((ledger.no_findings_reason ?? '').trim() !== '') {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return (
|
|
174
|
+
'the ledger exists with an empty `findings` array and no `no_findings_reason`. '
|
|
175
|
+
+ 'An unexplained empty ledger is indistinguishable from an absent one — state '
|
|
176
|
+
+ 'whether the review ran and reported nothing, or did not run, and how a reader '
|
|
177
|
+
+ 'can check that.'
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
139
181
|
export type ReleaseStatus = 'released' | 'unreleased' | 'undeterminable';
|
|
140
182
|
export type RemoteReleaseAnswer = 'released' | 'unreleased' | 'unavailable';
|
|
141
183
|
|
|
@@ -410,6 +452,19 @@ function main(argv: readonly string[]): number {
|
|
|
410
452
|
}
|
|
411
453
|
}
|
|
412
454
|
|
|
455
|
+
// An existing ledger that records nothing must say why. Checked here rather
|
|
456
|
+
// than in the schema because the requirement is conditional on the finding
|
|
457
|
+
// count, which the repo's Draft-07 subset validator cannot express.
|
|
458
|
+
if (fs.existsSync(ledgerPath)) {
|
|
459
|
+
const emptyProblem = empty_ledger_problem(ledger);
|
|
460
|
+
if (emptyProblem !== null) {
|
|
461
|
+
process.stderr.write(
|
|
462
|
+
`❌ ${path.relative(REPO_ROOT, ledgerPath)}: ${emptyProblem}\n`,
|
|
463
|
+
);
|
|
464
|
+
return 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
413
468
|
// Scope declaration, not a scope guard. The ledger is written per release by
|
|
414
469
|
// `--ingest`, so "no <release>.json" is the normal state of a release whose
|
|
415
470
|
// self-review reported nothing — the gate's own success line says so. That
|
|
@@ -418,7 +473,7 @@ function main(argv: readonly string[]): number {
|
|
|
418
473
|
// durable trigger for an un-ingested finding is `--pr` mode, which compares
|
|
419
474
|
// the ledger against what the self-review actually reported.
|
|
420
475
|
try {
|
|
421
|
-
|
|
476
|
+
reportScanned({
|
|
422
477
|
gate: 'check_finding_dispositions',
|
|
423
478
|
scanned: ledger.findings.length,
|
|
424
479
|
units: 'recorded finding(s)',
|
|
@@ -426,11 +481,13 @@ function main(argv: readonly string[]): number {
|
|
|
426
481
|
allowEmpty:
|
|
427
482
|
'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
|
|
428
483
|
+ 'self-review reported none — the ledger file is created on first --ingest, so a '
|
|
429
|
-
+ 'clean release legitimately has no <version>.json. NARROWED:
|
|
484
|
+
+ 'clean release legitimately has no <version>.json. NARROWED TWICE: it no longer '
|
|
430
485
|
+ 'covers a RELEASED version with no ledger, which returns 1 above on the '
|
|
431
|
-
+ 'released/unreleased split
|
|
432
|
-
+ '
|
|
433
|
-
+ '
|
|
486
|
+
+ 'released/unreleased split, and it no longer covers an empty ledger that does '
|
|
487
|
+
+ 'not say why it is empty, which returns 1 above via empty_ledger_problem. What '
|
|
488
|
+
+ 'remains is an EXPLAINED empty ledger and an unreleased version. Un-ingested '
|
|
489
|
+
+ 'findings on an in-flight release are caught by --pr (unrecorded_findings), '
|
|
490
|
+
+ 'never by this count.',
|
|
434
491
|
});
|
|
435
492
|
} catch (exc) {
|
|
436
493
|
if (exc instanceof DeadScopeError) {
|
|
@@ -113,6 +113,21 @@ export interface GateSpec {
|
|
|
113
113
|
* `pending` is reported instead of hidden.
|
|
114
114
|
*/
|
|
115
115
|
unavailable_exit?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Repo-relative workflow file this row's `argv` is pinned to.
|
|
118
|
+
*
|
|
119
|
+
* Rule 2 of this manifest — CI-IDENTICAL INVOCATION — was an instruction to
|
|
120
|
+
* the author and nothing else: the guard runs whatever `argv` says and never
|
|
121
|
+
* looked at how CI calls the gate, so a row could drift from its workflow, or
|
|
122
|
+
* the workflow step could be deleted outright, and this file would keep
|
|
123
|
+
* reporting healthy coverage for a gate CI no longer runs.
|
|
124
|
+
*
|
|
125
|
+
* Opt-in per row, because most gates here have several CI invocations with
|
|
126
|
+
* different arguments and one `argv` cannot be identical to all of them. Where
|
|
127
|
+
* a row DOES name a workflow, that workflow must contain an invocation of the
|
|
128
|
+
* gate whose argument list equals `argv` exactly.
|
|
129
|
+
*/
|
|
130
|
+
ci_invocation?: string;
|
|
116
131
|
note?: string;
|
|
117
132
|
}
|
|
118
133
|
|
|
@@ -191,6 +206,9 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
|
|
|
191
206
|
...(e['unavailable_exit'] === undefined
|
|
192
207
|
? {}
|
|
193
208
|
: { unavailable_exit: _require_int(e['unavailable_exit'], id, i) }),
|
|
209
|
+
...(e['ci_invocation'] === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { ci_invocation: _require_str(e['ci_invocation'], 'ci_invocation', id, i) }),
|
|
194
212
|
...(e['note'] === undefined ? {} : { note: String(e['note']) }),
|
|
195
213
|
...(e['canary'] === undefined ? {} : { canary: _require_canary(e['canary'], id, i) }),
|
|
196
214
|
...(e['no_canary_reason'] === undefined
|
|
@@ -405,6 +423,73 @@ function report_negative_control_inventory(specs: readonly GateSpec[]): void {
|
|
|
405
423
|
}
|
|
406
424
|
}
|
|
407
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Invocations of `src/scripts/<id>` in a workflow, each as its argument list.
|
|
428
|
+
*
|
|
429
|
+
* Deliberately a text scan rather than a YAML walk: an invocation lives inside a
|
|
430
|
+
* `run:` block scalar, so parsing the workflow buys nothing and costs the ability
|
|
431
|
+
* to see a call split across a line continuation, which is how the release job
|
|
432
|
+
* writes this very gate. Backslash-newline joins are folded before matching for
|
|
433
|
+
* that reason.
|
|
434
|
+
*/
|
|
435
|
+
export function ci_invocations(workflowText: string, id: string): string[][] {
|
|
436
|
+
const folded = workflowText.replace(/\\\n\s*/gu, ' ');
|
|
437
|
+
const out: string[][] = [];
|
|
438
|
+
const re = new RegExp(String.raw`src/scripts/${id}(?![A-Za-z0-9_])([^\n]*)`, 'gu');
|
|
439
|
+
for (const m of folded.matchAll(re)) {
|
|
440
|
+
out.push(
|
|
441
|
+
(m[1] ?? '')
|
|
442
|
+
.trim()
|
|
443
|
+
.split(/\s+/u)
|
|
444
|
+
.filter((t) => t !== ''),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The row's `argv` must be reproducible from the workflow it pins.
|
|
452
|
+
*
|
|
453
|
+
* Both directions are failures and they are different defects: no matching
|
|
454
|
+
* invocation means the manifest probes the gate in a way CI does not (or CI
|
|
455
|
+
* stopped calling it at all), which is the drift rule 2 asks for and nothing
|
|
456
|
+
* enforced.
|
|
457
|
+
*/
|
|
458
|
+
export function ci_invocation_problem(
|
|
459
|
+
workflowText: string,
|
|
460
|
+
id: string,
|
|
461
|
+
argv: readonly string[],
|
|
462
|
+
): string | null {
|
|
463
|
+
const found = ci_invocations(workflowText, id);
|
|
464
|
+
if (found.length === 0) {
|
|
465
|
+
return `no invocation of src/scripts/${id} — the workflow does not call this gate`;
|
|
466
|
+
}
|
|
467
|
+
const want = argv.join(' ');
|
|
468
|
+
if (found.some((a) => a.join(' ') === want)) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
return (
|
|
472
|
+
`argv [${want}] matches no invocation there; the workflow calls it as ` +
|
|
473
|
+
found.map((a) => `[${a.join(' ')}]`).join(', ')
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Rows whose `ci_invocation` no longer describes their workflow. */
|
|
478
|
+
export function ci_invocation_drift(specs: readonly GateSpec[], repoRoot = REPO_ROOT): string[] {
|
|
479
|
+
const problems: string[] = [];
|
|
480
|
+
for (const s of specs) {
|
|
481
|
+
if (s.ci_invocation === undefined) continue;
|
|
482
|
+
const file = path.join(repoRoot, s.ci_invocation);
|
|
483
|
+
if (!fs.existsSync(file)) {
|
|
484
|
+
problems.push(`${s.id}: ci_invocation ${s.ci_invocation} does not exist`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const problem = ci_invocation_problem(fs.readFileSync(file, 'utf-8'), s.id, s.argv);
|
|
488
|
+
if (problem !== null) problems.push(`${s.id}: ${s.ci_invocation} — ${problem}`);
|
|
489
|
+
}
|
|
490
|
+
return problems;
|
|
491
|
+
}
|
|
492
|
+
|
|
408
493
|
export function main(argv: readonly string[]): number {
|
|
409
494
|
const wantJson = argv.includes('--format') && argv[argv.indexOf('--format') + 1] === 'json';
|
|
410
495
|
const listOnly = argv.includes('--list');
|
|
@@ -523,12 +608,23 @@ export function main(argv: readonly string[]): number {
|
|
|
523
608
|
);
|
|
524
609
|
}
|
|
525
610
|
report_negative_control_inventory(specs);
|
|
611
|
+
const drift = ci_invocation_drift(specs);
|
|
612
|
+
for (const d of drift) {
|
|
613
|
+
process.stdout.write(` ❌ ${d}\n`);
|
|
614
|
+
}
|
|
526
615
|
const hardening = report_hardening_ratchet();
|
|
527
616
|
const selfTest = report_self_test_ratchet();
|
|
528
617
|
if (failed.length > 0) {
|
|
529
618
|
process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
|
|
530
619
|
return 1;
|
|
531
620
|
}
|
|
621
|
+
if (drift.length > 0) {
|
|
622
|
+
process.stdout.write(
|
|
623
|
+
`❌ ${String(drift.length)} row(s) declare a ci_invocation their workflow no longer matches — ` +
|
|
624
|
+
`the manifest probes the gate differently from CI, or CI stopped calling it.\n`,
|
|
625
|
+
);
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
532
628
|
if (hardening !== 0) {
|
|
533
629
|
return hardening;
|
|
534
630
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* The documented MCP client snippets must match the entry the installer writes.
|
|
4
|
+
*
|
|
5
|
+
* `docs/mcp-server.md` documented an absolute `tsx` path in four snippets while
|
|
6
|
+
* the installer wrote an `npx` invocation, so a consumer who followed the docs
|
|
7
|
+
* and a consumer who ran the installer landed on two different servers — for
|
|
8
|
+
* long enough that nobody noticed either was wrong.
|
|
9
|
+
*
|
|
10
|
+
* Scope is two literals in this tree. It asserts NOTHING about host behaviour:
|
|
11
|
+
* that a host reads the file at all is a separate question this gate does not
|
|
12
|
+
* touch and must not be cited for.
|
|
13
|
+
*
|
|
14
|
+
* Exit codes: 0 in sync · 1 drift · 2 a source is missing or unreadable.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
22
|
+
import { MCP_PACKAGE_NAME, MCP_SERVER_KEY, mcpBridgeEntry } from './_lib/mcp_bridge.js';
|
|
23
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
24
|
+
|
|
25
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
27
|
+
const DOC_REL = 'docs/mcp-server.md';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A snippet is any fenced block naming our server key.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately not a JSON/YAML parse: the four documented clients use three
|
|
33
|
+
* different shapes (`command` as a string, `command` as an object with `path`,
|
|
34
|
+
* and a YAML list), and a parser per shape would drift from the docs as surely
|
|
35
|
+
* as the docs drifted from the installer. The two facts that matter — the
|
|
36
|
+
* runner and the package spec — are the same literals in all three.
|
|
37
|
+
*/
|
|
38
|
+
export function snippetBlocks(text: string): string[] {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
const lines = text.split('\n');
|
|
41
|
+
let open: string[] | null = null;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (line.startsWith('```')) {
|
|
44
|
+
if (open === null) open = [];
|
|
45
|
+
else {
|
|
46
|
+
const body = open.join('\n');
|
|
47
|
+
if (body.includes(MCP_SERVER_KEY) || body.includes('mcpServers')) out.push(body);
|
|
48
|
+
open = null;
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (open !== null) open.push(line);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DocFinding {
|
|
58
|
+
snippet: number;
|
|
59
|
+
reason: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkSnippets(text: string, packageRoot: string): DocFinding[] {
|
|
63
|
+
const server = (mcpBridgeEntry(packageRoot)['mcpServers'] as Record<string, unknown>)[
|
|
64
|
+
MCP_SERVER_KEY
|
|
65
|
+
] as { command: string; args: string[] };
|
|
66
|
+
const findings: DocFinding[] = [];
|
|
67
|
+
|
|
68
|
+
snippetBlocks(text).forEach((body, i) => {
|
|
69
|
+
// Only blocks that actually configure the server — a block that merely
|
|
70
|
+
// mentions `mcpServers` in prose is not a client configuration.
|
|
71
|
+
if (!body.includes('command')) return;
|
|
72
|
+
if (!body.includes(server.command)) {
|
|
73
|
+
findings.push({
|
|
74
|
+
snippet: i + 1,
|
|
75
|
+
reason: `does not use the installer's runner \`${server.command}\``,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (!body.includes(MCP_PACKAGE_NAME)) {
|
|
79
|
+
findings.push({
|
|
80
|
+
snippet: i + 1,
|
|
81
|
+
reason: `does not name the package \`${MCP_PACKAGE_NAME}\``,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// QUOTED, not a substring: `mcp-server` contains `serve`, so a plain
|
|
85
|
+
// `includes` reported a snippet as current after the entry had been
|
|
86
|
+
// changed to `serve` and the doc had not — measured, on this gate's own
|
|
87
|
+
// sensitivity probe. Every documented shape quotes the token.
|
|
88
|
+
const subcommand = server.args[server.args.length - 1] as string;
|
|
89
|
+
if (!body.includes(`"${subcommand}"`)) {
|
|
90
|
+
findings.push({
|
|
91
|
+
snippet: i + 1,
|
|
92
|
+
reason: `does not invoke \`${subcommand}\``,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (body.includes('node_modules/.bin/tsx')) {
|
|
96
|
+
findings.push({
|
|
97
|
+
snippet: i + 1,
|
|
98
|
+
reason: 'documents an absolute tsx path the installer never writes',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function main(): number {
|
|
106
|
+
if (process.argv.slice(2).includes('--self-test')) return selfTest();
|
|
107
|
+
const rootIdx = process.argv.indexOf('--root');
|
|
108
|
+
const root = rootIdx === -1 ? ROOT : (process.argv[rootIdx + 1] ?? ROOT);
|
|
109
|
+
const doc = path.join(root, DOC_REL);
|
|
110
|
+
if (!fs.existsSync(doc)) {
|
|
111
|
+
process.stderr.write(`❌ ${DOC_REL} not found under ${root}\n`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const text = fs.readFileSync(doc, 'utf-8');
|
|
115
|
+
const blocks = snippetBlocks(text).filter((b) => b.includes('command'));
|
|
116
|
+
const findings = checkSnippets(text, root);
|
|
117
|
+
|
|
118
|
+
reportScanned({
|
|
119
|
+
gate: 'check_mcp_doc_drift',
|
|
120
|
+
scanned: blocks.length,
|
|
121
|
+
units: 'documented client snippet(s)',
|
|
122
|
+
roots: [DOC_REL],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (findings.length > 0) {
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
process.stdout.write(`❌ ${DOC_REL} snippet ${String(f.snippet)}: ${f.reason}\n`);
|
|
128
|
+
}
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
`\n${String(findings.length)} finding(s). The documented entry and the entry ` +
|
|
131
|
+
'`mcpBridgeEntry()` writes have drifted apart.\n',
|
|
132
|
+
);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
`✅ check_mcp_doc_drift: ${String(blocks.length)} documented snippet(s) match the ` +
|
|
137
|
+
'installer entry.\n',
|
|
138
|
+
);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One rejecting case per way the two literals can drift. */
|
|
143
|
+
function selfTest(): number {
|
|
144
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-doc-drift-'));
|
|
145
|
+
const write = (name: string, doc: string): string => {
|
|
146
|
+
const dir = path.join(tmp, name);
|
|
147
|
+
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
|
148
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' }));
|
|
149
|
+
fs.writeFileSync(path.join(dir, DOC_REL), doc, 'utf-8');
|
|
150
|
+
return dir;
|
|
151
|
+
};
|
|
152
|
+
const run = (root: string): number =>
|
|
153
|
+
runGateCli(ROOT, 'src/scripts/check_mcp_doc_drift.ts', ['--root', root], root);
|
|
154
|
+
|
|
155
|
+
const good = [
|
|
156
|
+
'```json',
|
|
157
|
+
'{ "mcpServers": { "agent-config": {',
|
|
158
|
+
' "command": "npx",',
|
|
159
|
+
' "args": ["-y", "@event4u/agent-config@<version>", "mcp-server"] } } }',
|
|
160
|
+
'```',
|
|
161
|
+
'',
|
|
162
|
+
].join('\n');
|
|
163
|
+
const tsxPath = good.replace('"npx"', '"/abs/agent-config/node_modules/.bin/tsx"');
|
|
164
|
+
const noPackage = good.replace('@event4u/agent-config@<version>', 'some-other-package');
|
|
165
|
+
const noSubcommand = good.replace('"mcp-server"', '"serve"');
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
return runSelfTest({
|
|
169
|
+
gate: 'check_mcp_doc_drift',
|
|
170
|
+
minCases: 4,
|
|
171
|
+
minRejectCases: 3,
|
|
172
|
+
cases: [
|
|
173
|
+
{
|
|
174
|
+
name: 'an absolute tsx path is rejected',
|
|
175
|
+
expect: 'reject',
|
|
176
|
+
run: () => run(write('tsx', tsxPath)),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'a snippet naming another package is rejected',
|
|
180
|
+
expect: 'reject',
|
|
181
|
+
run: () => run(write('pkg', noPackage)),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'a snippet invoking another subcommand is rejected',
|
|
185
|
+
expect: 'reject',
|
|
186
|
+
run: () => run(write('sub', noSubcommand)),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'the installer-shaped snippet passes',
|
|
190
|
+
expect: 'accept',
|
|
191
|
+
run: () => run(write('good', good)),
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _isCliEntry(): boolean {
|
|
201
|
+
if (process.argv[1] === undefined) return false;
|
|
202
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
|
|
203
|
+
try {
|
|
204
|
+
return (
|
|
205
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
206
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
207
|
+
);
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
214
|
+
process.exit(main());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { DOC_REL, main };
|