@event4u/agent-config 9.12.0 → 9.13.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.
Files changed (130) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +80 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/rules/active-remediation.md +0 -2
  5. package/dist/agent-src/rules/architecture.md +0 -1
  6. package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
  7. package/dist/agent-src/rules/autonomous-execution.md +1 -2
  8. package/dist/agent-src/rules/broken-access-control.md +0 -2
  9. package/dist/agent-src/rules/code-comment-discipline.md +0 -3
  10. package/dist/agent-src/rules/code-provenance.md +0 -3
  11. package/dist/agent-src/rules/communication-through-line.md +2 -3
  12. package/dist/agent-src/rules/content-quoting-floor.md +0 -2
  13. package/dist/agent-src/rules/context-hygiene.md +1 -3
  14. package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
  15. package/dist/agent-src/rules/design-fidelity.md +29 -0
  16. package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
  17. package/dist/agent-src/rules/downstream-changes.md +10 -1
  18. package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
  19. package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
  20. package/dist/agent-src/rules/git-history-discipline.md +0 -7
  21. package/dist/agent-src/rules/guidelines.md +0 -2
  22. package/dist/agent-src/rules/improve-before-implement.md +4 -2
  23. package/dist/agent-src/rules/invite-challenge.md +0 -4
  24. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
  25. package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
  26. package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
  27. package/dist/agent-src/rules/missing-tool-handling.md +0 -1
  28. package/dist/agent-src/rules/no-attribution-footers.md +0 -3
  29. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
  30. package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
  31. package/dist/agent-src/rules/no-roadmap-references.md +0 -2
  32. package/dist/agent-src/rules/output-discipline.md +0 -1
  33. package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
  34. package/dist/agent-src/rules/preservation-guard.md +0 -3
  35. package/dist/agent-src/rules/role-mode-adherence.md +0 -1
  36. package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
  37. package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
  38. package/dist/agent-src/rules/session-canary.md +0 -2
  39. package/dist/agent-src/rules/size-enforcement.md +0 -5
  40. package/dist/agent-src/rules/source-confidentiality.md +0 -3
  41. package/dist/agent-src/rules/source-discovery-gate.md +0 -2
  42. package/dist/agent-src/rules/think-before-action.md +3 -3
  43. package/dist/agent-src/rules/token-budget-discipline.md +0 -1
  44. package/dist/agent-src/rules/token-efficiency.md +1 -2
  45. package/dist/agent-src/rules/tool-safety.md +0 -1
  46. package/dist/agent-src/rules/user-interaction.md +0 -3
  47. package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
  48. package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
  49. package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
  50. package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
  51. package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
  52. package/dist/agent-src/skills/design-review/SKILL.md +11 -0
  53. package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
  54. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
  55. package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
  56. package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
  57. package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
  58. package/dist/agent-src/skills/performance/SKILL.md +14 -0
  59. package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
  60. package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
  61. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
  62. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
  63. package/dist/agent-src/skills/video-director/SKILL.md +9 -6
  64. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  65. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  66. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  67. package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  68. package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
  69. package/dist/discovery/deprecation-report.md +1 -1
  70. package/dist/discovery/discovery-manifest.json +89 -67
  71. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  72. package/dist/discovery/discovery-manifest.summary.md +3 -3
  73. package/dist/discovery/orphan-report.md +1 -1
  74. package/dist/discovery/packs.json +6 -5
  75. package/dist/discovery/trust-report.md +2 -2
  76. package/dist/discovery/workspaces.json +5 -4
  77. package/dist/hooks/dispatch.js +174 -13
  78. package/dist/mcp/registry-manifest.json +3 -3
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +750 -507
  81. package/dist/router.json +1 -1
  82. package/dist/scripts/condense.js +2 -2
  83. package/docs/MIGRATION.md +1 -1
  84. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
  85. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
  86. package/docs/guidelines/code-clarity.md +9 -2
  87. package/docs/guidelines/design-fidelity-mechanics.md +44 -0
  88. package/llms.txt +63 -27
  89. package/package.json +4 -4
  90. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
  91. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
  92. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
  93. package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
  94. package/src/config/agent-settings.template.yml +8 -0
  95. package/src/scripts/_lib/counted_probe.ts +142 -0
  96. package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
  97. package/src/scripts/_lib/release_scope.ts +207 -0
  98. package/src/scripts/ai_council/stance_tally.ts +37 -2
  99. package/src/scripts/audit_auto_rules.ts +9 -9
  100. package/src/scripts/audit_likelihood.ts +3 -3
  101. package/src/scripts/audit_overlap.ts +2 -2
  102. package/src/scripts/audit_skill_overlap.ts +233 -95
  103. package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
  104. package/src/scripts/build_proof.ts +15 -1
  105. package/src/scripts/check_dependency_floors.ts +16 -6
  106. package/src/scripts/check_gate_coverage.ts +6 -6
  107. package/src/scripts/check_kernel_rule_bundle.ts +5 -4
  108. package/src/scripts/check_token_quality_golden.ts +0 -18
  109. package/src/scripts/code_graph/loader.ts +35 -6
  110. package/src/scripts/compile_router.ts +3 -8
  111. package/src/scripts/condense.ts +2 -2
  112. package/src/scripts/explain_run.ts +0 -1
  113. package/src/scripts/generate_pack_manifests.ts +3 -20
  114. package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
  115. package/src/scripts/hooks/block_no_verify.ts +101 -0
  116. package/src/scripts/lint_agent_security.ts +5 -4
  117. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  118. package/src/scripts/lint_regression.ts +92 -1
  119. package/src/scripts/lint_skill_descriptions.ts +93 -5
  120. package/src/scripts/project_thin_rules.ts +2 -2
  121. package/src/scripts/release.ts +107 -3
  122. package/src/scripts/resolve_lint_scope.ts +53 -0
  123. package/src/scripts/router_telemetry.ts +0 -5
  124. package/src/scripts/runtime_dispatcher.ts +40 -0
  125. package/src/scripts/schemas/pack.schema.json +1 -5
  126. package/src/scripts/schemas/rule.schema.json +1 -2
  127. package/src/scripts/self_review_gate.ts +15 -73
  128. package/src/scripts/skill_linter.ts +93 -11
  129. package/src/scripts/sweep_dead_scan_roots.ts +550 -0
  130. package/src/scripts/trigger_coverage.ts +11 -37
@@ -44,6 +44,33 @@ export const PLACEHOLDER_PATTERNS: ReadonlyArray<string> = [
44
44
  'xxx',
45
45
  ];
46
46
 
47
+ /**
48
+ * Optional brief key carrying a user-supplied design artifact.
49
+ *
50
+ * The brief's five required keys are a lossy projection of a finished
51
+ * artifact: `layout` is prose, not a spacing scale, and there is no slot at
52
+ * all for easing, hover behaviour, handlers, or assets. Before this key the
53
+ * pipeline had no channel for a handed-over design, so it rebuilt one from
54
+ * the five-key abstraction and said nothing about the difference. This slot
55
+ * is that channel — one more input to the same step, never a parallel track.
56
+ */
57
+ export const PROVIDED_ARTIFACT_KEY = 'provided_artifact';
58
+
59
+ /**
60
+ * What the five-key brief cannot carry from a provided artifact.
61
+ *
62
+ * Stated to the user **before** any regeneration, not discovered afterwards.
63
+ * A supplied `design-system.json` closes the first two; the rest are closed
64
+ * only by the coverage report `apply` demands.
65
+ */
66
+ export const UNCARRIED_BY_THE_BRIEF: ReadonlyArray<string> = [
67
+ 'exact spacing values — `layout` is prose, not a scale',
68
+ 'easing curves and animation timing',
69
+ 'hover / focus / active behaviour',
70
+ 'event handlers and the interactions they drive',
71
+ 'the asset manifest (images, icons, fonts the artifact references)',
72
+ ];
73
+
47
74
  export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
48
75
  {
49
76
  code: 'design_missing',
@@ -61,6 +88,17 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
61
88
  'agent re-runs the brief with final strings; ' +
62
89
  'halt lists the offending microcopy keys',
63
90
  },
91
+ {
92
+ code: 'design_provided_without_contract',
93
+ trigger:
94
+ 'state.ui_design.provided_artifact is set but carries no ' +
95
+ 'design_system, and the loss has not been acknowledged — the ' +
96
+ 'brief would silently regenerate what it cannot carry',
97
+ resolution:
98
+ 'user supplies a design-system.json (honoured verbatim), or ' +
99
+ 'acknowledges the named losses so the port proceeds with them ' +
100
+ 'stated up front',
101
+ },
64
102
  {
65
103
  code: 'design_unconfirmed',
66
104
  trigger: 'brief is well-formed but design_confirmed is unset/False',
@@ -123,6 +161,11 @@ export function run(state: DeliveryState): StepResult {
123
161
  return _halt_placeholders(state, placeholders);
124
162
  }
125
163
 
164
+ const provided = provided_artifact(designDict);
165
+ if (provided !== null && !_contract_or_loss_acknowledged(provided)) {
166
+ return _halt_provided_without_contract(state, provided);
167
+ }
168
+
126
169
  if (designDict['design_confirmed'] === true) {
127
170
  return new StepResult({ outcome: Outcome.SUCCESS });
128
171
  }
@@ -238,6 +281,81 @@ function _walk_placeholders(node: Any, prefix: string, violations: string[]): vo
238
281
  }
239
282
  }
240
283
 
284
+ /**
285
+ * Return the supplied-artifact envelope, or `null` when none was handed over.
286
+ *
287
+ * Exported because `apply` needs the same answer — the coverage report it
288
+ * demands exists only for a port, and a second copy of "is this a port?"
289
+ * would be the drift this package has paid for before (see the shared
290
+ * `placeholder_paths` walker above).
291
+ */
292
+ export function provided_artifact(
293
+ design: Record<string, Any> | null | undefined,
294
+ ): Record<string, Any> | null {
295
+ if (!_isDict(design)) return null;
296
+ const provided = design[PROVIDED_ARTIFACT_KEY];
297
+ if (!_isDict(provided) || Object.keys(provided).length === 0) return null;
298
+ return provided;
299
+ }
300
+
301
+ /** True when a machine-readable contract came with the artifact. */
302
+ export function has_design_system(provided: Record<string, Any>): boolean {
303
+ return _isDict(provided['design_system']) && _pyTruthy(provided['design_system']);
304
+ }
305
+
306
+ /** The port may proceed: either a contract exists, or the losses were stated and accepted. */
307
+ function _contract_or_loss_acknowledged(provided: Record<string, Any>): boolean {
308
+ return has_design_system(provided) || provided['loss_acknowledged'] === true;
309
+ }
310
+
311
+ /**
312
+ * BLOCKED halt — an artifact was handed over with no contract behind it.
313
+ *
314
+ * This is the honest-refusal branch. The pipeline *can* rebuild the artifact
315
+ * from the five-key brief; what it must not do is rebuild it silently. The
316
+ * halt names every value the brief cannot carry, then asks. Answering
317
+ * option 1 sets `loss_acknowledged` and the same losses are on the record.
318
+ */
319
+ function _halt_provided_without_contract(
320
+ state: DeliveryState,
321
+ provided: Record<string, Any>,
322
+ ): StepResult {
323
+ const preview = _preview_input(state);
324
+ const ref = typeof provided['path'] === 'string' && provided['path'] !== ''
325
+ ? String(provided['path'])
326
+ : 'the provided artifact';
327
+ const lines: string[] = [
328
+ `> Input: ${preview}`,
329
+ `> \`${ref}\` is a finished design handed over as the spec, and no ` +
330
+ '`design-system.json` came with it. The design brief carries five ' +
331
+ 'keys (layout, components, states, microcopy, a11y) — everything ' +
332
+ 'below is outside them and would be rebuilt from taste rather than ' +
333
+ 'read from your artifact:',
334
+ ];
335
+ for (const loss of UNCARRIED_BY_THE_BRIEF) {
336
+ lines.push(`> - ${loss}`);
337
+ }
338
+ lines.push(
339
+ '> 1. Proceed — port it with those five losses on the record; `apply` ' +
340
+ 'still has to report what it honoured, translated, and flagged',
341
+ '> 2. Supply a `design-system.json` — its token values are then ' +
342
+ 'honoured verbatim instead of re-derived',
343
+ '> 3. Abort — drop this UI request',
344
+ '',
345
+ '**Recommendation: 2 — Supply the contract** — it is the only option ' +
346
+ 'that closes the spacing and easing losses at the source. Caveat: ' +
347
+ 'pick 1 when no extractor is available; the port then proceeds with ' +
348
+ 'the gap stated rather than hidden.',
349
+ );
350
+ return new StepResult({
351
+ outcome: Outcome.BLOCKED,
352
+ questions: lines,
353
+ message:
354
+ 'UI design halted: a provided artifact carries no design-system.json; ' +
355
+ `${UNCARRIED_BY_THE_BRIEF.length} value class(es) cannot be carried by the brief.`,
356
+ });
357
+ }
358
+
241
359
  /** Render a one-line preview of the input being designed. */
242
360
  function _preview_input(state: DeliveryState): string {
243
361
  const data = (_isDict(state.ticket) ? state.ticket : {}) as Record<string, Any>;
@@ -116,6 +116,41 @@ function _pyTruthy(value: Any): boolean {
116
116
  return true;
117
117
  }
118
118
 
119
+ /**
120
+ * Findings the provided artifact already answers — informational, never
121
+ * fix-worthy.
122
+ *
123
+ * A port carries its source's aesthetic on purpose. Left unfiltered, a
124
+ * finding like "cream palette reads as scaffold-default" is indistinguishable
125
+ * from a real defect, so the polish loop spends a round editing the port away
126
+ * from the spec the user approved — the exact regression `daf-slop-vs-provided`
127
+ * witnesses. Marking the finding rather than deleting it keeps it visible in
128
+ * the review output; what changes is that it no longer drives a round.
129
+ *
130
+ * Scope is strict, and the burden sits with the marker: only a decision the
131
+ * artifact **actually covers** may carry the flag. Generative work in the same
132
+ * run is not artifact-covered and stays fully subject to the anti-slop scan.
133
+ */
134
+ export const ARTIFACT_COVERED_KEY = 'artifact_covered';
135
+
136
+ /** True when this finding is answered by the provided artifact. */
137
+ function _is_artifact_covered(finding: Any): boolean {
138
+ return _isDict(finding) && finding[ARTIFACT_COVERED_KEY] === true;
139
+ }
140
+
141
+ /** Split findings into the ones that drive rounds and the informational rest. */
142
+ export function partition_artifact_covered(findings: ReadonlyArray<Any>): {
143
+ actionable: Any[];
144
+ informational: Any[];
145
+ } {
146
+ const actionable: Any[] = [];
147
+ const informational: Any[] = [];
148
+ for (const finding of findings) {
149
+ (_is_artifact_covered(finding) ? informational : actionable).push(finding);
150
+ }
151
+ return { actionable, informational };
152
+ }
153
+
119
154
  /** Apply the polish-loop gate. */
120
155
  export function run(state: DeliveryState): StepResult {
121
156
  const review = _pyTruthy(state.ui_review) ? (state.ui_review as Record<string, Any>) : {};
@@ -125,6 +160,11 @@ export function run(state: DeliveryState): StepResult {
125
160
  }
126
161
  const review_clean = _pyTruthy(review['review_clean']);
127
162
 
163
+ // Artifact-covered findings are dropped from the round-driving set before
164
+ // anything else reads it — including the ceiling check, so a port cannot
165
+ // burn its two rounds on findings it was never allowed to act on.
166
+ findings = partition_artifact_covered(findings as Any[]).actionable;
167
+
128
168
  if (review_clean || (findings as Any[]).length === 0) {
129
169
  return new StepResult({ outcome: Outcome.SUCCESS });
130
170
  }
@@ -669,6 +669,61 @@ function _validate_ui_design(ui_design: JsonValue): void {
669
669
  `required state) or null; got ${pyTypeName(ui_design['states'])}`,
670
670
  );
671
671
  }
672
+ _validate_provided_artifact(ui_design['provided_artifact']);
673
+ }
674
+
675
+ /**
676
+ * Reject a malformed `ui_design.provided_artifact` envelope; tolerate absence.
677
+ *
678
+ * The slot is the port case's only carrier, and both gates that consume it
679
+ * (`design`'s honest-refusal halt, `apply`'s coverage ledger) branch on its
680
+ * shape. Shape-checking here rather than in the handlers means a `interactions:
681
+ * "three"` cannot quietly reduce the coverage ledger to zero declared items —
682
+ * the same bypass-by-type that `states: "n/a"` used to achieve against the
683
+ * five-state loop.
684
+ */
685
+ function _validate_provided_artifact(provided: JsonValue | undefined): void {
686
+ if (provided === undefined || provided === null) {
687
+ return;
688
+ }
689
+ if (!_isDict(provided)) {
690
+ throw new SchemaError(
691
+ 'state.ui_design.provided_artifact must be a JSON object or null; ' +
692
+ `got ${pyTypeName(provided)}`,
693
+ );
694
+ }
695
+ if ('path' in provided && typeof provided['path'] !== 'string') {
696
+ throw new SchemaError(
697
+ 'state.ui_design.provided_artifact.path must be a string when present',
698
+ );
699
+ }
700
+ if (
701
+ 'design_system' in provided &&
702
+ provided['design_system'] !== null &&
703
+ !_isDict(provided['design_system'])
704
+ ) {
705
+ throw new SchemaError(
706
+ 'state.ui_design.provided_artifact.design_system must be a JSON ' +
707
+ `object or null; got ${pyTypeName(provided['design_system'])}`,
708
+ );
709
+ }
710
+ if (
711
+ 'loss_acknowledged' in provided &&
712
+ typeof provided['loss_acknowledged'] !== 'boolean'
713
+ ) {
714
+ throw new SchemaError(
715
+ 'state.ui_design.provided_artifact.loss_acknowledged must be a ' +
716
+ 'boolean when present',
717
+ );
718
+ }
719
+ for (const key of ['interactions', 'keyframes', 'assets']) {
720
+ if (key in provided && !Array.isArray(provided[key])) {
721
+ throw new SchemaError(
722
+ `state.ui_design.provided_artifact.${key} must be a list of ` +
723
+ `strings when present; got ${pyTypeName(provided[key])}`,
724
+ );
725
+ }
726
+ }
672
727
  }
673
728
 
674
729
  /**
@@ -1115,6 +1115,14 @@ hooks:
1115
1115
  # is permanent; deprecation notice lands at the next major, removal the
1116
1116
  # major after — unless external evidence (a consumer case the graph answers
1117
1117
  # and grep cannot) appears first.
1118
+ #
1119
+ # The engine's parser pair is NO LONGER a runtime dependency (~51 MB that
1120
+ # every consumer installed for a path none of them can reach); it is a
1121
+ # devDependency, which npm does not install for consumers. Flipping this to
1122
+ # `true` is therefore not sufficient — install the ABI-locked pair too:
1123
+ # npm i web-tree-sitter@0.24.7 tree-sitter-wasms@0.1.13
1124
+ # Both versions are exact because the pair is ABI-coupled (grammar ABI 14).
1125
+ # The loader raises this same hint if the pair is missing.
1118
1126
  code_graph:
1119
1127
  enabled: false
1120
1128
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Bounded shell-out for probes that parse a count or a list out of stdout.
3
+ *
4
+ * `spawnSync` buffers the child's whole stdout in memory and fails with
5
+ * `ENOBUFS` past `maxBuffer`, whose default is 1 MiB. A probe that treats that
6
+ * failure as "no output" reports **zero** — and zero, for a counting probe, is
7
+ * indistinguishable from a clean result. That is how a release shipped without
8
+ * its `Tests:` footer: the `vitest list` output had grown to 1,254,812 bytes,
9
+ * the probe degraded silently, and the omission surfaced only as a red gate
10
+ * downstream.
11
+ *
12
+ * Raising the buffer alone just moves the cliff. The invariant this module
13
+ * enforces is the other half: **a truncated read is an error, never a zero.**
14
+ *
15
+ * The distinction that matters:
16
+ *
17
+ * - `ENOBUFS` — the command *succeeded* and we lost its output. There is no
18
+ * honest count to report, so this throws.
19
+ * - `ENOENT` / non-zero exit — the command did not run, or ran and failed.
20
+ * The caller may legitimately degrade (a dev box without `npx`), so these
21
+ * are reported as a typed outcome rather than thrown.
22
+ *
23
+ * Scope note: this is deliberately not a general exec wrapper. It covers the
24
+ * counting/listing class, where a short read silently manufactures a passing
25
+ * result.
26
+ */
27
+ import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
28
+
29
+ /**
30
+ * Default ceiling: 64 MiB.
31
+ *
32
+ * ~50× the largest listing this repository currently produces, at no cost
33
+ * when the output is small — `maxBuffer` is a ceiling, not an allocation.
34
+ */
35
+ export const DEFAULT_PROBE_MAX_BUFFER = 64 * 1024 * 1024;
36
+
37
+ /** Raised when a probe's output exceeded the buffer, so its count is unknowable. */
38
+ export class ProbeOverflowError extends Error {
39
+ constructor(
40
+ readonly command: string,
41
+ readonly maxBuffer: number,
42
+ ) {
43
+ super(
44
+ `probe \`${command}\` exceeded its ${Math.round(maxBuffer / (1024 * 1024))} MiB ` +
45
+ 'output buffer — the read was truncated, so any count derived from it ' +
46
+ 'would be wrong. This is an error rather than a zero on purpose: a ' +
47
+ 'counting probe that degrades to zero reports a clean result it never ' +
48
+ 'measured. Raise the buffer or narrow the command.',
49
+ );
50
+ this.name = 'ProbeOverflowError';
51
+ }
52
+ }
53
+
54
+ export interface ProbeOptions {
55
+ readonly cwd?: string;
56
+ readonly maxBuffer?: number;
57
+ readonly timeout?: number;
58
+ readonly env?: NodeJS.ProcessEnv;
59
+ }
60
+
61
+ export interface ProbeResult {
62
+ /** True when the command ran and exited 0. */
63
+ readonly ok: boolean;
64
+ readonly stdout: string;
65
+ readonly stderr: string;
66
+ readonly status: number | null;
67
+ /** Populated when the command could not be run or failed; never set for overflow (that throws). */
68
+ readonly failure: string | null;
69
+ }
70
+
71
+ /**
72
+ * Run a command and capture stdout with an explicit buffer ceiling.
73
+ *
74
+ * @throws {ProbeOverflowError} when the child's output exceeded `maxBuffer`.
75
+ */
76
+ export function runCountedProbe(
77
+ command: string,
78
+ args: readonly string[],
79
+ opts: ProbeOptions = {},
80
+ ): ProbeResult {
81
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_PROBE_MAX_BUFFER;
82
+ const res: SpawnSyncReturns<string> = spawnSync(command, [...args], {
83
+ cwd: opts.cwd,
84
+ encoding: 'utf-8',
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ maxBuffer,
87
+ ...(opts.timeout === undefined ? {} : { timeout: opts.timeout }),
88
+ ...(opts.env === undefined ? {} : { env: opts.env }),
89
+ });
90
+ return interpretProbeResult(res, `${command} ${args.join(' ')}`.trim(), maxBuffer);
91
+ }
92
+
93
+ /**
94
+ * Classify a finished spawn. Split out from `runCountedProbe` so the failure
95
+ * modes are unit-testable without spawning a real, slow, large-output child.
96
+ *
97
+ * @throws {ProbeOverflowError} on `ENOBUFS`.
98
+ */
99
+ export function interpretProbeResult(
100
+ res: {
101
+ error?: (Error & { code?: string }) | undefined;
102
+ status: number | null;
103
+ stdout: string | null;
104
+ stderr?: string | null;
105
+ },
106
+ command: string,
107
+ maxBuffer: number = DEFAULT_PROBE_MAX_BUFFER,
108
+ ): ProbeResult {
109
+ const stdout = res.stdout ?? '';
110
+ const stderr = res.stderr ?? '';
111
+ if (res.error) {
112
+ if (res.error.code === 'ENOBUFS') {
113
+ throw new ProbeOverflowError(command, maxBuffer);
114
+ }
115
+ return {
116
+ ok: false,
117
+ stdout,
118
+ stderr,
119
+ status: res.status,
120
+ failure: `${res.error.code ?? res.error.message}`,
121
+ };
122
+ }
123
+ if ((res.status ?? 1) !== 0) {
124
+ return {
125
+ ok: false,
126
+ stdout,
127
+ stderr,
128
+ status: res.status,
129
+ failure: `exited ${res.status ?? '(null)'}`,
130
+ };
131
+ }
132
+ return { ok: true, stdout, stderr, status: res.status, failure: null };
133
+ }
134
+
135
+ /** Non-empty, trimmed lines of a successful probe — the usual "list" shape. */
136
+ export function probeLines(result: ProbeResult): string[] {
137
+ if (!result.ok) return [];
138
+ return result.stdout
139
+ .split('\n')
140
+ .map((l) => l.trim())
141
+ .filter((l) => l.length > 0);
142
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Output-contract parser + scorer for `overbuild-review-lens`.
3
+ *
4
+ * The lens's own SKILL.md states an output contract. This is the half of that
5
+ * contract a machine can check: tag grammar, the mandatory Chesterton's-Fence
6
+ * line on every `delete:` finding, the honest-null form, and the sign of the
7
+ * net-lines figure. Whether a live model *finds the plant* in a fixture is not
8
+ * checkable here — that needs a scored eval run — and the split is stated in
9
+ * `tests/fixtures/overbuild-lens/README.md` rather than blurred.
10
+ *
11
+ * Deliberately dependency-free and side-effect-free: parse a string, return a
12
+ * verdict. Nothing reads the filesystem or the network.
13
+ */
14
+
15
+ /** The six tags the lens may emit. Anything else is a contract violation. */
16
+ export const LENS_TAGS = ['delete', 'stdlib', 'native', 'yagni', 'shrink', 'flatten'] as const;
17
+ export type LensTag = (typeof LENS_TAGS)[number];
18
+
19
+ export const LENS_VERDICTS = ['lean', 'trim', 'overbuilt'] as const;
20
+ export type LensVerdict = (typeof LENS_VERDICTS)[number];
21
+
22
+ export interface LensFinding {
23
+ tag: LensTag;
24
+ location: string;
25
+ text: string;
26
+ /** Present only on `delete:` findings; the contract requires it there. */
27
+ fence?: { why: string; safe: string; covered: string };
28
+ }
29
+
30
+ export interface ParsedLensOutput {
31
+ verdict: LensVerdict | null;
32
+ findings: LensFinding[];
33
+ isNull: boolean;
34
+ netLines: number | null;
35
+ /** Contract violations found while parsing. Empty means the shape is legal. */
36
+ errors: string[];
37
+ }
38
+
39
+ const _TAG_RE = new RegExp(
40
+ String.raw`^\s*\d+\.\s+(` + LENS_TAGS.join('|') + String.raw`):\s+(\S+)\s+(.*\S)\s*$`,
41
+ );
42
+ const _FENCE_RE =
43
+ /^\s*Fence:\s*why=(.+?)\s+safe=(.+?)\s+covered=(yes|no|partial)\s*$/;
44
+ const _VERDICT_RE = /^\s*Verdict:\s*(\S+)\s*$/;
45
+ const _NET_RE = /^\s*Net:\s*([+-]?\d+)\s+lines?\s*$/;
46
+ const _NULL_RE = /^\s*Findings:\s*none\b/;
47
+ const _COVERED_VALUES = new Set(['yes', 'no', 'partial']);
48
+
49
+ /**
50
+ * Parse a lens verdict block. Never throws — a malformed block comes back with
51
+ * a populated `errors` array, because a parser that throws on bad input cannot
52
+ * report *how many* ways the input was bad.
53
+ */
54
+ export function parseLensOutput(raw: string): ParsedLensOutput {
55
+ const lines = raw.split('\n');
56
+ const errors: string[] = [];
57
+ const findings: LensFinding[] = [];
58
+ let verdict: LensVerdict | null = null;
59
+ let netLines: number | null = null;
60
+ let isNull = false;
61
+
62
+ for (let i = 0; i < lines.length; i += 1) {
63
+ const line: string = lines[i] ?? '';
64
+
65
+ const v = line.match(_VERDICT_RE);
66
+ if (v) {
67
+ if ((LENS_VERDICTS as readonly string[]).includes(v[1] ?? '')) {
68
+ verdict = v[1] as LensVerdict;
69
+ } else {
70
+ errors.push(`unknown verdict \`${v[1]}\` (allowed: ${LENS_VERDICTS.join('/')})`);
71
+ }
72
+ continue;
73
+ }
74
+
75
+ const n = line.match(_NET_RE);
76
+ if (n) {
77
+ netLines = Number.parseInt(n[1] ?? '0', 10);
78
+ continue;
79
+ }
80
+
81
+ if (_NULL_RE.test(line)) {
82
+ isNull = true;
83
+ continue;
84
+ }
85
+
86
+ const m = line.match(_TAG_RE);
87
+ if (!m) {
88
+ // An unrecognised numbered line is a finding the grammar rejects.
89
+ if (/^\s*\d+\.\s+\S+:/.test(line)) {
90
+ errors.push(`line ${i + 1}: not a legal tag — \`${line.trim()}\``);
91
+ }
92
+ continue;
93
+ }
94
+
95
+ const finding: LensFinding = {
96
+ tag: m[1] as LensTag,
97
+ location: m[2] ?? '',
98
+ text: m[3] ?? '',
99
+ };
100
+
101
+ if (finding.tag === 'delete') {
102
+ const fenceLine: string = lines[i + 1] ?? '';
103
+ const f = fenceLine.match(_FENCE_RE);
104
+ if (!f) {
105
+ errors.push(
106
+ `line ${i + 1}: \`delete:\` finding at ${finding.location} has no valid ` +
107
+ 'Fence line (why= / safe= / covered=yes|no|partial)',
108
+ );
109
+ } else {
110
+ const covered = f[3] ?? '';
111
+ finding.fence = {
112
+ why: (f[1] ?? '').trim(),
113
+ safe: (f[2] ?? '').trim(),
114
+ covered,
115
+ };
116
+ if (!_COVERED_VALUES.has(covered)) {
117
+ errors.push(`line ${i + 2}: covered= must be yes|no|partial`);
118
+ }
119
+ }
120
+ }
121
+
122
+ findings.push(finding);
123
+ }
124
+
125
+ if (verdict === null) errors.push('no `Verdict:` line');
126
+ if (netLines === null) errors.push('no `Net: <n> lines` line');
127
+ if (isNull && findings.length > 0) {
128
+ errors.push('emitted the null AND findings — pick one');
129
+ }
130
+ if (!isNull && findings.length === 0) {
131
+ errors.push('no findings and no null block — the null is mandatory, not optional');
132
+ }
133
+ if (verdict === 'lean' && findings.length > 0) {
134
+ errors.push('`lean` verdict with findings — a lean diff has nothing to cut');
135
+ }
136
+ if (verdict !== 'lean' && isNull) {
137
+ errors.push('null block with a non-`lean` verdict');
138
+ }
139
+
140
+ return { verdict, findings, isNull, netLines, errors };
141
+ }
142
+
143
+ export interface ExpectedLabels {
144
+ verdict: string;
145
+ must_tags: string[];
146
+ forbidden_tags: string[];
147
+ must_be_null: boolean;
148
+ net_sign: 'negative' | 'zero' | 'positive';
149
+ requires_fence?: boolean;
150
+ }
151
+
152
+ /**
153
+ * Score a parsed output against a fixture's expected labels. Returns the list
154
+ * of failures — empty means the output is correct for that fixture.
155
+ */
156
+ export function scoreAgainstExpected(
157
+ parsed: ParsedLensOutput,
158
+ expected: ExpectedLabels,
159
+ ): string[] {
160
+ const failures = [...parsed.errors];
161
+
162
+ if (parsed.verdict !== expected.verdict) {
163
+ failures.push(`verdict ${parsed.verdict} ≠ expected ${expected.verdict}`);
164
+ }
165
+ if (parsed.isNull !== expected.must_be_null) {
166
+ failures.push(
167
+ expected.must_be_null
168
+ ? 'expected the honest null, got findings (invented a finding on a lean diff)'
169
+ : 'expected findings, got the null (missed the plant)',
170
+ );
171
+ }
172
+
173
+ const seen = new Set(parsed.findings.map((f) => f.tag));
174
+ for (const tag of expected.must_tags) {
175
+ if (!seen.has(tag as LensTag)) failures.push(`missing required tag \`${tag}:\``);
176
+ }
177
+ for (const tag of expected.forbidden_tags) {
178
+ if (seen.has(tag as LensTag)) failures.push(`emitted forbidden tag \`${tag}:\``);
179
+ }
180
+
181
+ if (expected.requires_fence) {
182
+ const unfenced = parsed.findings.filter((f) => f.tag === 'delete' && !f.fence);
183
+ if (unfenced.length > 0) failures.push('a `delete:` finding is missing its fence');
184
+ }
185
+
186
+ const sign =
187
+ parsed.netLines === null ? 'unknown'
188
+ : parsed.netLines < 0 ? 'negative'
189
+ : parsed.netLines > 0 ? 'positive'
190
+ : 'zero';
191
+ if (sign !== expected.net_sign) {
192
+ failures.push(`net-lines sign ${sign} ≠ expected ${expected.net_sign}`);
193
+ }
194
+
195
+ return failures;
196
+ }