@agjs/tsforge 0.2.6 → 0.2.8

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.
@@ -109,6 +109,21 @@
109
109
  "bad": "[1, 2, 3].reduce((arr, num) => arr.concat(num * 2), [] as number[]);\n\n['a', 'b'].reduce(\n (accumulator, name) => ({\n ...accumulator,\n [name]: true,\n }),\n {} as Record<string, boolean>,",
110
110
  "good": "[1, 2, 3].reduce<number[]>((arr, num) => arr.concat(num * 2), []);\n\n['a', 'b'].reduce<Record<string, boolean>>(\n (accumulator, name) => ({\n ...accumulator,\n [name]: true,\n }),\n {},"
111
111
  },
112
+ "tsforge/id-param-requires-object-authz": {
113
+ "what": "Warn when a handler reads `params.id` and queries the database without an authorization check in the same function.",
114
+ "bad": "// Example that violates the rule",
115
+ "good": "// Corrected version"
116
+ },
117
+ "tsforge/mutating-route-requires-authz": {
118
+ "what": "POST/PUT/PATCH/DELETE route handlers must call an authorization helper before mutating state.",
119
+ "bad": "// Example that violates the rule",
120
+ "good": "// Corrected version"
121
+ },
122
+ "tsforge/server-action-requires-authz": {
123
+ "what": "Files with `\"use server\"` that perform database mutations must call an authorization helper in the same function.",
124
+ "bad": "// Example that violates the rule",
125
+ "good": "// Corrected version"
126
+ },
112
127
  "tsforge/job-name-must-be-constant": {
113
128
  "what": "Disallow string-literal job names in `<queue>.add(name, ...)` calls — use a constant identifier so all consumers share one source of truth.",
114
129
  "bad": "// Example that violates the rule",
@@ -219,6 +234,16 @@
219
234
  "bad": "// Example that violates the rule",
220
235
  "good": "// Corrected version"
221
236
  },
237
+ "tsforge/update-delete-account-scoped-must-filter-scope": {
238
+ "what": "Require Drizzle `.update()` / `.delete()` against account-scoped tables to filter by a scope column in `.where()`.",
239
+ "bad": "// Example that violates the rule",
240
+ "good": "// Corrected version"
241
+ },
242
+ "tsforge/update-delete-must-have-where": {
243
+ "what": "Require every Drizzle `.update()` and `.delete()` call to include a `.where()` clause — unscoped writes affect every row.",
244
+ "bad": "// Example that violates the rule",
245
+ "good": "// Corrected version"
246
+ },
222
247
  "tsforge/consistent-status-via-set": {
223
248
  "what": "Inside Elysia route handlers, set HTTP status via `set.status = N`, not by returning a `new Response(body, { status: N })`.",
224
249
  "bad": "// Example that violates the rule",
@@ -319,11 +344,26 @@
319
344
  "bad": "// Example that violates the rule",
320
345
  "good": "// Corrected version"
321
346
  },
347
+ "tsforge/auth-cookie-must-set-maxage-or-expires": {
348
+ "what": "Auth-cookie writes should set `maxAge` or `expires` so session cookies do not live forever by default.",
349
+ "bad": "// Example that violates the rule",
350
+ "good": "// Corrected version"
351
+ },
352
+ "tsforge/auth-cookie-must-set-samesite": {
353
+ "what": "Auth-cookie writes must set `sameSite` (`strict` or `lax`) — missing SameSite allows cross-site cookie delivery.",
354
+ "bad": "// Example that violates the rule",
355
+ "good": "// Corrected version"
356
+ },
322
357
  "tsforge/bcrypt-rounds-min": {
323
358
  "what": "Disallow `bcrypt.hash` / `bcrypt.hashSync` calls with a numeric-literal rounds value below the configured minimum (default 10).",
324
359
  "bad": "// Example that violates the rule",
325
360
  "good": "// Corrected version"
326
361
  },
362
+ "tsforge/jwt-must-verify-not-decode": {
363
+ "what": "Disallow `jwt.decode` / `decodeJwt` — decoding without verification accepts forged tokens. Use `jwt.verify` or `jwtVerify` instead.",
364
+ "bad": "// Example that violates the rule",
365
+ "good": "// Corrected version"
366
+ },
327
367
  "tsforge/no-import-build-output": {
328
368
  "what": "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
329
369
  "bad": "// Example that violates the rule",
@@ -354,6 +394,11 @@
354
394
  "bad": "// Example that violates the rule",
355
395
  "good": "// Corrected version"
356
396
  },
397
+ "tsforge/mutation-should-revalidate-cache": {
398
+ "what": "After database mutations in server actions or route handlers, call `revalidatePath` or `revalidateTag` so cached pages reflect the change.",
399
+ "bad": "// Example that violates the rule",
400
+ "good": "// Corrected version"
401
+ },
357
402
  "tsforge/no-html-img-element": {
358
403
  "what": "Prefer next/image over raw <img> elements for optimized responsive images and Core Web Vitals.",
359
404
  "bad": "// Example that violates the rule",
@@ -374,6 +419,11 @@
374
419
  "bad": "// Example that violates the rule",
375
420
  "good": "// Corrected version"
376
421
  },
422
+ "tsforge/no-secret-props-to-client": {
423
+ "what": "Warn when Server Components pass secret-looking props to JSX — values may cross the client boundary.",
424
+ "bad": "// Example that violates the rule",
425
+ "good": "// Corrected version"
426
+ },
377
427
  "tsforge/no-sensitive-next-public-env": {
378
428
  "what": "Disallow NEXT_PUBLIC_* env vars whose names suggest secrets — public build-time vars are visible in the client bundle.",
379
429
  "bad": "// Example that violates the rule",
@@ -384,6 +434,16 @@
384
434
  "bad": "// Example that violates the rule",
385
435
  "good": "// Corrected version"
386
436
  },
437
+ "tsforge/server-action-requires-authz-and-validation": {
438
+ "what": "Server actions (`\"use server\"`) that mutate the database must call authorization helpers and validate input with `.parse()` / `.safeParse()`.",
439
+ "bad": "// Example that violates the rule",
440
+ "good": "// Corrected version"
441
+ },
442
+ "tsforge/server-only-modules-import-server-only": {
443
+ "what": "App-router server modules must import `\"server-only\"` so accidental client bundling fails at build time.",
444
+ "bad": "// Example that violates the rule",
445
+ "good": "// Corrected version"
446
+ },
387
447
  "tsforge/pkce-required-for-oidc": {
388
448
  "what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
389
449
  "bad": "// Example that violates the rule",
@@ -399,8 +459,13 @@
399
459
  "bad": "// Example that violates the rule",
400
460
  "good": "// Corrected version"
401
461
  },
462
+ "tsforge/component-file-purity": {
463
+ "what": "A component .tsx contains only imports and the component itself — types go to <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib",
464
+ "bad": "// Example that violates the rule",
465
+ "good": "// Corrected version"
466
+ },
402
467
  "tsforge/component-folder-structure": {
403
- "what": "Enforce required sibling files in component folders (hooks, types, stories, test, index)",
468
+ "what": "A component .tsx must live in src/views/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views/<Feature>/index.tsx",
404
469
  "bad": "// Example that violates the rule",
405
470
  "good": "// Corrected version"
406
471
  },
@@ -469,6 +534,31 @@
469
534
  "bad": "// Example that violates the rule",
470
535
  "good": "// Corrected version"
471
536
  },
537
+ "tsforge/no-prototype-polluting-merge": {
538
+ "what": "Disallow merging request body/query/params into objects — enables prototype pollution.",
539
+ "bad": "// Example that violates the rule",
540
+ "good": "// Corrected version"
541
+ },
542
+ "tsforge/no-user-controlled-fetch-url": {
543
+ "what": "Disallow fetch/axios requests to non-literal URLs — dynamic URLs enable SSRF.",
544
+ "bad": "// Example that violates the rule",
545
+ "good": "// Corrected version"
546
+ },
547
+ "tsforge/no-user-controlled-redirect": {
548
+ "what": "Disallow redirects to non-literal URLs — user-controlled redirects enable open redirects.",
549
+ "bad": "// Example that violates the rule",
550
+ "good": "// Corrected version"
551
+ },
552
+ "tsforge/upload-must-set-limits": {
553
+ "what": "Multipart upload handlers should declare `limits` or `maxFileSize` to bound request size.",
554
+ "bad": "// Example that violates the rule",
555
+ "good": "// Corrected version"
556
+ },
557
+ "tsforge/webhook-must-verify-signature-before-parse": {
558
+ "what": "Webhook handlers must verify signatures before calling `.json()` on the request body.",
559
+ "bad": "// Example that violates the rule",
560
+ "good": "// Corrected version"
561
+ },
472
562
  "tsforge/catch-must-handle": {
473
563
  "what": "Catch blocks must log, rethrow, or propagate errors — not silently return empty defaults on failure.",
474
564
  "bad": "// Example that violates the rule",
@@ -499,6 +589,16 @@
499
589
  "bad": "// Example that violates the rule",
500
590
  "good": "// Corrected version"
501
591
  },
592
+ "tsforge/caught-error-log-requires-cause": {
593
+ "what": "When logging a caught error, include a `cause` field in the structured payload so downstream tools preserve the error chain.",
594
+ "bad": "// Example that violates the rule",
595
+ "good": "// Corrected version"
596
+ },
597
+ "tsforge/logger-not-console": {
598
+ "what": "Service modules should use the structured logger instead of `console.*` — console output is unstructured and hard to search.",
599
+ "bad": "// Example that violates the rule",
600
+ "good": "// Corrected version"
601
+ },
502
602
  "tsforge/mask-pii-fields": {
503
603
  "what": "Disallow unmasked PII (email, phone, password, token, ...) in structured-logger payloads — the #1 way data leaks quietly.",
504
604
  "bad": "// Example that violates the rule",
@@ -519,14 +619,49 @@
519
619
  "bad": "// Example that violates the rule",
520
620
  "good": "// Corrected version"
521
621
  },
622
+ "tsforge/fake-timers-must-be-restored": {
623
+ "what": "When a test file calls `useFakeTimers()`, it must also call `useRealTimers()` so later tests are not affected.",
624
+ "bad": "// Example that violates the rule",
625
+ "good": "// Corrected version"
626
+ },
627
+ "tsforge/no-conditional-expect": {
628
+ "what": "Disallow `expect()` inside conditionals — tests must fail when assertions are skipped.",
629
+ "bad": "// Example that violates the rule",
630
+ "good": "// Corrected version"
631
+ },
522
632
  "tsforge/no-focused-tests": {
523
633
  "what": "Disallow focused tests (`test.only`, `it.only`, `fdescribe`, ...) — the canonical 'I forgot to remove this before committing' leak.",
524
634
  "bad": "// Example that violates the rule",
525
635
  "good": "// Corrected version"
526
636
  },
637
+ "tsforge/no-real-network-in-unit-tests": {
638
+ "what": "Unit tests should not perform real network I/O — mock HTTP clients or move the test to an integration suite.",
639
+ "bad": "// Example that violates the rule",
640
+ "good": "// Corrected version"
641
+ },
527
642
  "tsforge/test-file-mirrors-source": {
528
643
  "what": "Every test file under `tests/` must mirror a source file under `src/`. Catches orphaned tests left behind after refactors and renames.",
529
644
  "bad": "// Example that violates the rule",
530
645
  "good": "// Corrected version"
646
+ },
647
+ "tsforge/exported-functions-require-return-type": {
648
+ "what": "Exported functions should declare an explicit return type at module boundaries.",
649
+ "bad": "// Example that violates the rule",
650
+ "good": "// Corrected version"
651
+ },
652
+ "tsforge/fetch-must-check-ok": {
653
+ "what": "HTTP fetch responses must check `.ok` or status before calling `.json()`.",
654
+ "bad": "// Example that violates the rule",
655
+ "good": "// Corrected version"
656
+ },
657
+ "tsforge/json-parse-must-validate": {
658
+ "what": "Disallow bare JSON.parse on untrusted input — validate through a schema library.",
659
+ "bad": "// Example that violates the rule",
660
+ "good": "// Corrected version"
661
+ },
662
+ "tsforge/no-unsafe-boundary-cast": {
663
+ "what": "Disallow type assertions immediately after parsing untrusted boundary input.",
664
+ "bad": "// Example that violates the rule",
665
+ "good": "// Corrected version"
531
666
  }
532
667
  }
package/src/loop/run.ts CHANGED
@@ -1,16 +1,25 @@
1
- import { join } from "node:path";
2
1
  import type { ITask } from "../spec";
3
2
  import type { IChatMessage, IModelResponse, IProvider } from "../inference";
4
3
  import { validate, type ErrorParser } from "../validate";
5
4
  import { parseEslintJson } from "../validate";
6
5
  import { readFiles } from "../lib/fs";
7
6
  import { RUN_STATUS, STUCK_REASON, LOOP_LIMITS } from "./loop.constants";
8
- import type { IRunResult, IRunOptions, Reporter } from "./loop.types";
7
+ import type {
8
+ IRunResult,
9
+ IRunOptions,
10
+ Reporter,
11
+ ILoopEvent,
12
+ } from "./loop.types";
13
+ import { mineLessons, consolidate as consolidateMemory } from "./memory";
9
14
  import { flags } from "../config";
10
15
  import { SYSTEM, seedPrompt } from "./prompt";
11
16
  import { detectStack } from "../stack-detection";
12
- import { TtsrManager, parseProjectRules, type ITtsrRule } from "./ttsr";
13
- import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
17
+ import type { TtsrManager } from "./ttsr";
18
+ import {
19
+ initTtsrManager,
20
+ loadProjectTtsrRules,
21
+ applyTtsrInterrupt,
22
+ } from "./ttsr-init";
14
23
  import {
15
24
  type ILoopCtx,
16
25
  type ILoopState,
@@ -66,57 +75,14 @@ function handleDegeneration(
66
75
  };
67
76
  }
68
77
 
69
- /** Read and parse `<cwd>/.tsforge/rules.json` if present. Missing or invalid
70
- * files yield no rules (parseProjectRules tolerates malformed JSON). */
71
- export async function loadProjectTtsrRules(cwd: string): Promise<ITtsrRule[]> {
72
- const file = Bun.file(join(cwd, ".tsforge", "rules.json"));
73
-
74
- if (!(await file.exists())) {
75
- return [];
76
- }
78
+ // TTSR init + project/learned-rule loading live in the shared ttsr-init module
79
+ // (the interactive session uses the same loaders). Re-exported here so existing
80
+ // importers (and tests) keep their path.
81
+ export { initTtsrManager, loadProjectTtsrRules };
77
82
 
78
- return parseProjectRules(await file.text());
79
- }
80
-
81
- /** Build and configure a TTSR manager if enabled. Returns null if disabled.
82
- * Built-in defaults register first, then optional project rules from
83
- * `<cwd>/.tsforge/rules.json`; `addRule` ignores duplicate names, so a
84
- * built-in safety rule always wins over a same-named project rule. */
85
- export async function initTtsrManager(
86
- cwd: string,
87
- report: Reporter,
88
- taskId: string
89
- ): Promise<TtsrManager | null> {
90
- if (!flags.ttsr()) {
91
- return null;
92
- }
93
-
94
- const manager = new TtsrManager();
95
-
96
- for (const rule of DEFAULT_TTSR_RULES) {
97
- manager.addRule(rule);
98
- }
99
-
100
- let added = 0;
101
-
102
- for (const rule of await loadProjectTtsrRules(cwd)) {
103
- if (manager.addRule(rule)) {
104
- added += 1;
105
- }
106
- }
107
-
108
- if (added > 0) {
109
- report({
110
- kind: "ttsr",
111
- task: taskId,
112
- message: `loaded ${added} custom TTSR rule(s) from .tsforge/rules.json`,
113
- });
114
- }
115
-
116
- return manager;
117
- }
118
-
119
- /** Handle a TTSR interrupt: report, inject corrective message, and optionally disable. */
83
+ /** Handle a TTSR interrupt in the headless loop: apply the shared interrupt
84
+ * (count, report, inject corrective guidance, disable at the cap) then emit
85
+ * timing for the interrupted turn. */
120
86
  function handleTtsrInterrupt(
121
87
  ttsrFired: { ruleName: string; guidance: string },
122
88
  state: ILoopState,
@@ -128,32 +94,41 @@ function handleTtsrInterrupt(
128
94
  taskStart: number,
129
95
  ttsrManager: TtsrManager | null
130
96
  ): void {
131
- state.ttsrInterrupts += 1;
132
-
133
- report({
134
- kind: "ttsr",
135
- task: taskId,
136
- message: `⚠ TTSR interrupted: ${ttsrFired.ruleName}`,
137
- });
138
-
139
- // Hard cap: after 3 interrupts, disable TTSR to prevent loops
140
- if (state.ttsrInterrupts >= 3) {
141
- report({
142
- kind: "tool",
143
- task: taskId,
144
- message: `TTSR disabled after ${state.ttsrInterrupts} interrupts (hit cap)`,
145
- });
97
+ applyTtsrInterrupt(ttsrFired, state, messages, report, taskId, ttsrManager);
98
+ emitTiming(report, taskId, turn, turnStart, taskStart);
99
+ }
146
100
 
147
- ttsrManager?.disable();
101
+ /**
102
+ * MEMORY post-run hook: mine this run's events for failure→fix lessons and
103
+ * consolidate them into `.tsforge/`. Gated on the TTSR flag (learned rules are
104
+ * recalled via TTSR, so there's nothing to learn for if it's off). Best-effort:
105
+ * a memory failure never affects the run's result. `runId` is unique per run so
106
+ * the same task re-run counts as a distinct session for the recurrence gate.
107
+ */
108
+ async function consolidateLessons(
109
+ cwd: string,
110
+ events: readonly ILoopEvent[],
111
+ runId: string,
112
+ report: Reporter
113
+ ): Promise<void> {
114
+ if (!flags.ttsr()) {
115
+ return;
148
116
  }
149
117
 
150
- // Append corrective message and retry without counting as a normal cycle
151
- messages.push({
152
- role: "user",
153
- content: `⚠ generation interrupted: ${ttsrFired.guidance} Rewrite the affected part without that pattern.`,
154
- });
118
+ try {
119
+ const candidates = mineLessons(events);
120
+ const active = await consolidateMemory(cwd, candidates, runId);
155
121
 
156
- emitTiming(report, taskId, turn, turnStart, taskStart);
122
+ if (active > 0) {
123
+ report({
124
+ kind: "ttsr",
125
+ task: runId,
126
+ message: `memory: ${String(active)} learned rule(s) active in .tsforge/learned-rules.json`,
127
+ });
128
+ }
129
+ } catch {
130
+ // Memory is supplementary — never let it break a run.
131
+ }
157
132
  }
158
133
 
159
134
  /** Assemble per-call completion options, leaving optional knobs unset when absent. */
@@ -242,7 +217,25 @@ export async function runTask(
242
217
  const effectiveParse = effectiveParserFor(parse);
243
218
  const temperature = opts.temperature ?? 0;
244
219
  const maxTurns = opts.maxTurns ?? LOOP_LIMITS.maxTurns;
245
- const report: Reporter = opts.onEvent ?? (() => undefined);
220
+ // Buffer every event so the post-run memory hook can mine the run for
221
+ // failure→fix lessons, while still forwarding live to the real reporter.
222
+ const base: Reporter = opts.onEvent ?? (() => undefined);
223
+ const events: ILoopEvent[] = [];
224
+
225
+ const report: Reporter = (event) => {
226
+ events.push(event);
227
+ base(event);
228
+ };
229
+
230
+ // Unique per run, so re-running the same task counts as a distinct session for
231
+ // the lesson-recurrence gate.
232
+ const runId = `${task.id}-${Date.now().toString(36)}`;
233
+
234
+ const finish = async (result: IRunResult): Promise<IRunResult> => {
235
+ await consolidateLessons(cwd, events, runId, report);
236
+
237
+ return result;
238
+ };
246
239
 
247
240
  report({
248
241
  kind: "start",
@@ -330,6 +323,7 @@ export async function runTask(
330
323
  const state: ILoopState = {
331
324
  prevGateErrors: red.errors,
332
325
  gateNoProgress: 0,
326
+ errorAge: new Map(),
333
327
  lastGateCount: -1,
334
328
  edits: 0,
335
329
  regressions: 0,
@@ -402,7 +396,7 @@ export async function runTask(
402
396
  });
403
397
 
404
398
  if (looped !== null) {
405
- return looped;
399
+ return finish(looped);
406
400
  }
407
401
 
408
402
  const touchedEditable =
@@ -418,11 +412,11 @@ export async function runTask(
418
412
  emitTiming(report, task.id, turn, turnStart, taskStart);
419
413
 
420
414
  if (settled !== null) {
421
- return {
415
+ return finish({
422
416
  ...settled,
423
417
  edits: state.edits,
424
418
  regressions: state.regressions,
425
- };
419
+ });
426
420
  }
427
421
 
428
422
  // Stopped with no tool call while still red → nudge it to act, not narrate.
@@ -443,7 +437,7 @@ export async function runTask(
443
437
  message: `task ${task.id}: stuck (hit ${maxTurns}-turn cap)`,
444
438
  });
445
439
 
446
- return {
440
+ return finish({
447
441
  task: task.id,
448
442
  redConfirmed: true,
449
443
  status: RUN_STATUS.stuck,
@@ -451,5 +445,5 @@ export async function runTask(
451
445
  reason: STUCK_REASON.cap,
452
446
  edits: state.edits,
453
447
  regressions: state.regressions,
454
- };
448
+ });
455
449
  }