@webpieces/pr-gate 0.4.626 → 0.4.628

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/pr-gate",
3
- "version": "0.4.626",
3
+ "version": "0.4.628",
4
4
  "description": "Gated PR system: 3-point squash-merge, merge validation gate, and red/yellow/green PR dashboard. Standalone scripts, no Nx dependency required.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -15,7 +15,7 @@
15
15
  "directory": "packages/tooling/pr-gate"
16
16
  },
17
17
  "dependencies": {
18
- "@webpieces/rules-config": "0.4.626",
18
+ "@webpieces/rules-config": "0.4.628",
19
19
  "@inversifyjs/binding-decorators": "1.1.5",
20
20
  "inversify": "7.10.4",
21
21
  "reflect-metadata": "0.2.2"
@@ -9,7 +9,10 @@ import { AgedTreeSweeper, DotWebpieces, RepoRootFinder } from '@webpieces/rules-
9
9
  * the active logs, the *-status.json state files), and every writer `mkdirSync(..., { recursive: true })`s
10
10
  * its target first — so pruning a stale home dir is harmless; the next write recreates it.
11
11
  *
12
- * There is exactly ONE root, because there is exactly one place webpieces writes state: `{repo}/.webpieces`.
12
+ * The roots are the two PER-WORKTREE scopes `DotWebpieces.local()` and `DotWebpieces.aiWritable()`
13
+ * which are one and the same directory in the primary clone and two in a linked worktree (see
14
+ * `aiWritable()` for why `pr-review/` has to sit in the worktree). The repo-wide `shared()` dir is never
15
+ * swept: its entries belong to every worktree at once.
13
16
  * This used to sweep a second, machine-global root (`~/.webpieces/prs/...`, which survived `rm -rf <clone>`
14
17
  * and so had no other owner). That store is gone — the gated merge body is the PR's own description now —
15
18
  * and with it the only artifact webpieces ever wrote outside a repo. See
@@ -21,5 +24,12 @@ export declare class CleanTmp {
21
24
  private readonly dotDir;
22
25
  constructor(repoRootFinder: RepoRootFinder, sweeper: AgedTreeSweeper, dotDir?: DotWebpieces);
23
26
  cleanTmp(): Promise<void>;
27
+ /**
28
+ * The per-worktree roots that exist, de-duplicated. `local()` and `aiWritable()` are the SAME path
29
+ * in the primary clone — sweeping it twice would double-count and print every removal twice — and
30
+ * two different paths in a linked worktree, where BOTH need reaping or `pr-review/` accumulates in
31
+ * the worktree forever.
32
+ */
33
+ private sweepBases;
24
34
  private reporter;
25
35
  }
@@ -17,7 +17,10 @@ const SEP = '━━━━━━━━━━━━━━━━━━━━━━
17
17
  * the active logs, the *-status.json state files), and every writer `mkdirSync(..., { recursive: true })`s
18
18
  * its target first — so pruning a stale home dir is harmless; the next write recreates it.
19
19
  *
20
- * There is exactly ONE root, because there is exactly one place webpieces writes state: `{repo}/.webpieces`.
20
+ * The roots are the two PER-WORKTREE scopes `DotWebpieces.local()` and `DotWebpieces.aiWritable()`
21
+ * which are one and the same directory in the primary clone and two in a linked worktree (see
22
+ * `aiWritable()` for why `pr-review/` has to sit in the worktree). The repo-wide `shared()` dir is never
23
+ * swept: its entries belong to every worktree at once.
21
24
  * This used to sweep a second, machine-global root (`~/.webpieces/prs/...`, which survived `rm -rf <clone>`
22
25
  * and so had no other owner). That store is gone — the gated merge body is the PR's own description now —
23
26
  * and with it the only artifact webpieces ever wrote outside a repo. See
@@ -34,17 +37,22 @@ let CleanTmp = class CleanTmp {
34
37
  }
35
38
  async cleanTmp() {
36
39
  const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());
37
- // LOCAL scope: a worktree garbage-collects its OWN merge-info/pr-review scratch dirs. It must
38
- // never sweep the repo-wide dir, whose entries (merged-branches.json, the main-sync status and
39
- // its lock) belong to every worktree at once.
40
- const tmpBase = this.dotDir.local(repoRoot);
41
- if (!fs.existsSync(tmpBase))
40
+ // The two per-worktree scopes, and only those. It must never sweep the repo-wide dir, whose
41
+ // entries (merged-branches.json, the main-sync status and its lock) belong to every worktree at
42
+ // once. In the primary clone these collapse to the same path and it is swept once.
43
+ // local() — merge-info/, instruct-ai/, logs/ in <primary>/.webpieces/worktrees/<name>
44
+ // aiWritable() — pr-review/ in <worktree>/.webpieces, where the agent can actually write it
45
+ const bases = this.sweepBases(repoRoot);
46
+ if (bases.length === 0)
42
47
  return;
43
48
  process.stdout.write('\n' + SEP + '🧹 Garbage-Collecting .webpieces\n' + SEP + '\n');
44
- process.stdout.write(`Location: ${tmpBase}\n`);
49
+ for (const base of bases)
50
+ process.stdout.write(`Location: ${base}\n`);
45
51
  process.stdout.write(`Retention: ${rules_config_1.RETENTION_DAYS} days (older files reaped; empty dirs pruned)\n\n`);
46
52
  const cutoffMs = rules_config_1.RETENTION_DAYS * 24 * 60 * 60 * 1000;
47
- const total = this.sweeper.sweep(tmpBase, cutoffMs, this.reporter(tmpBase));
53
+ const total = new rules_config_1.SweepCount();
54
+ for (const base of bases)
55
+ total.add(this.sweeper.sweep(base, cutoffMs, this.reporter(base)));
48
56
  if (total.empty) {
49
57
  process.stdout.write(` ✅ Nothing older than ${rules_config_1.RETENTION_DAYS} days; no empty directories\n`);
50
58
  }
@@ -55,6 +63,22 @@ let CleanTmp = class CleanTmp {
55
63
  }
56
64
  process.stdout.write('\n' + SEP + '\n');
57
65
  }
66
+ /**
67
+ * The per-worktree roots that exist, de-duplicated. `local()` and `aiWritable()` are the SAME path
68
+ * in the primary clone — sweeping it twice would double-count and print every removal twice — and
69
+ * two different paths in a linked worktree, where BOTH need reaping or `pr-review/` accumulates in
70
+ * the worktree forever.
71
+ */
72
+ sweepBases(repoRoot) {
73
+ const candidates = [this.dotDir.local(repoRoot), this.dotDir.aiWritable(repoRoot)];
74
+ const seen = new Set();
75
+ return candidates.filter((base) => {
76
+ if (seen.has(base) || !fs.existsSync(base))
77
+ return false;
78
+ seen.add(base);
79
+ return true;
80
+ });
81
+ }
58
82
  // One line per removal, relative to the root it came from, so the two roots read as one report.
59
83
  reporter(base) {
60
84
  return (removed, isDir) => {
@@ -1 +1 @@
1
- {"version":3,"file":"cleanTmp.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/cleanTmp.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAC7B,0DAEiC;AACjC,yCAA2D;AAE3D,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;;;;;;;;;;;;GAeG;AAEI,IAAM,QAAQ,GAAd,MAAM,QAAQ;IAEI;IACA;IACA;IAHrB,YACqB,cAA8B,EAC9B,OAAwB,EACxB,SAAuB,2BAAY;QAFnC,mBAAc,GAAd,cAAc,CAAgB;QAC9B,YAAO,GAAP,OAAO,CAAiB;QACxB,WAAM,GAAN,MAAM,CAA6B;IACrD,CAAC;IAEJ,KAAK,CAAC,QAAQ;QACV,MAAM,QAAQ,GAAG,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACpE,8FAA8F;QAC9F,+FAA+F;QAC/F,8CAA8C;QAC9C,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO;QAEpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,GAAG,GAAG,oCAAoC,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC;QACrF,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,aAAa,OAAO,IAAI,CAAC,CAAC;QAC/C,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,cAAc,6BAAc,mDAAmD,CAAC,CAAC;QAEtG,MAAM,QAAQ,GAAG,6BAAc,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;QACtD,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QAE5E,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YACd,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,0BAA0B,6BAAc,+BAA+B,CAAC,CAAC;QAClG,CAAC;aAAM,CAAC;YACJ,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;YACtD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,aAAa,CAAC;YAC/D,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,gBAAgB,KAAK,CAAC,KAAK,QAAQ,QAAQ,eAAe,KAAK,CAAC,IAAI,UAAU,OAAO,IAAI,CAAC,CAAC;QACpH,CAAC;QAED,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC;IAC5C,CAAC;IAED,gGAAgG;IACxF,QAAQ,CAAC,IAAY;QACzB,OAAO,CAAC,OAAe,EAAE,KAAc,EAAQ,EAAE;YAC7C,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;YAC1C,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;QACjG,CAAC,CAAC;IACN,CAAC;CACJ,CAAA;AAxCY,4BAAQ;mBAAR,QAAQ;IADpB,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAGA,6BAAc;QACrB,8BAAe;QAChB,2BAAY;GAJhC,QAAQ,CAwCpB","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport {\n AgedTreeSweeper, DotWebpieces, RETENTION_DAYS, RepoRootFinder, dotWebpieces,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * 30-day garbage collection of this tree's `.webpieces` scratch state.\n *\n * Runs at the end of every merge/PR flow (see merge-end.ts). Any file older than the cutoff is deleted\n * and any directory left empty afterwards is pruned; the root itself is never removed.\n *\n * Everything the tooling still needs is rewritten on each run under a fresh mtime (instruct-ai/ docs,\n * the active logs, the *-status.json state files), and every writer `mkdirSync(..., { recursive: true })`s\n * its target first — so pruning a stale home dir is harmless; the next write recreates it.\n *\n * There is exactly ONE root, because there is exactly one place webpieces writes state: `{repo}/.webpieces`.\n * This used to sweep a second, machine-global root (`~/.webpieces/prs/...`, which survived `rm -rf <clone>`\n * and so had no other owner). That store is gone — the gated merge body is the PR's own description now —\n * and with it the only artifact webpieces ever wrote outside a repo. See\n * `decisions/0005-the-pr-description-is-the-merge-body.md`.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanTmp {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly sweeper: AgedTreeSweeper,\n private readonly dotDir: DotWebpieces = dotWebpieces,\n ) {}\n\n async cleanTmp(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n // LOCAL scope: a worktree garbage-collects its OWN merge-info/pr-review scratch dirs. It must\n // never sweep the repo-wide dir, whose entries (merged-branches.json, the main-sync status and\n // its lock) belong to every worktree at once.\n const tmpBase = this.dotDir.local(repoRoot);\n if (!fs.existsSync(tmpBase)) return;\n\n process.stdout.write('\\n' + SEP + '🧹 Garbage-Collecting .webpieces\\n' + SEP + '\\n');\n process.stdout.write(`Location: ${tmpBase}\\n`);\n process.stdout.write(`Retention: ${RETENTION_DAYS} days (older files reaped; empty dirs pruned)\\n\\n`);\n\n const cutoffMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;\n const total = this.sweeper.sweep(tmpBase, cutoffMs, this.reporter(tmpBase));\n\n if (total.empty) {\n process.stdout.write(` ✅ Nothing older than ${RETENTION_DAYS} days; no empty directories\\n`);\n } else {\n const fileWord = total.files === 1 ? 'file' : 'files';\n const dirWord = total.dirs === 1 ? 'directory' : 'directories';\n process.stdout.write(`\\n ✅ Reaped ${total.files} old ${fileWord} and pruned ${total.dirs} empty ${dirWord}\\n`);\n }\n\n process.stdout.write('\\n' + SEP + '\\n');\n }\n\n // One line per removal, relative to the root it came from, so the two roots read as one report.\n private reporter(base: string): (removed: string, isDir: boolean) => void {\n return (removed: string, isDir: boolean): void => {\n const label = isDir ? 'dir: ' : 'file: ';\n process.stdout.write(` 🗑️ ${label} ${path.relative(base, removed)}${isDir ? '/' : ''}\\n`);\n };\n }\n}\n"]}
1
+ {"version":3,"file":"cleanTmp.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/cleanTmp.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport {\n AgedTreeSweeper, DotWebpieces, RETENTION_DAYS, RepoRootFinder, SweepCount, dotWebpieces,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * 30-day garbage collection of this tree's `.webpieces` scratch state.\n *\n * Runs at the end of every merge/PR flow (see merge-end.ts). Any file older than the cutoff is deleted\n * and any directory left empty afterwards is pruned; the root itself is never removed.\n *\n * Everything the tooling still needs is rewritten on each run under a fresh mtime (instruct-ai/ docs,\n * the active logs, the *-status.json state files), and every writer `mkdirSync(..., { recursive: true })`s\n * its target first — so pruning a stale home dir is harmless; the next write recreates it.\n *\n * The roots are the two PER-WORKTREE scopes `DotWebpieces.local()` and `DotWebpieces.aiWritable()` —\n * which are one and the same directory in the primary clone and two in a linked worktree (see\n * `aiWritable()` for why `pr-review/` has to sit in the worktree). The repo-wide `shared()` dir is never\n * swept: its entries belong to every worktree at once.\n * This used to sweep a second, machine-global root (`~/.webpieces/prs/...`, which survived `rm -rf <clone>`\n * and so had no other owner). That store is gone — the gated merge body is the PR's own description now —\n * and with it the only artifact webpieces ever wrote outside a repo. See\n * `decisions/0005-the-pr-description-is-the-merge-body.md`.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class CleanTmp {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly sweeper: AgedTreeSweeper,\n private readonly dotDir: DotWebpieces = dotWebpieces,\n ) {}\n\n async cleanTmp(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n // The two per-worktree scopes, and only those. It must never sweep the repo-wide dir, whose\n // entries (merged-branches.json, the main-sync status and its lock) belong to every worktree at\n // once. In the primary clone these collapse to the same path and it is swept once.\n // local() — merge-info/, instruct-ai/, logs/ in <primary>/.webpieces/worktrees/<name>\n // aiWritable() — pr-review/ in <worktree>/.webpieces, where the agent can actually write it\n const bases = this.sweepBases(repoRoot);\n if (bases.length === 0) return;\n\n process.stdout.write('\\n' + SEP + '🧹 Garbage-Collecting .webpieces\\n' + SEP + '\\n');\n for (const base of bases) process.stdout.write(`Location: ${base}\\n`);\n process.stdout.write(`Retention: ${RETENTION_DAYS} days (older files reaped; empty dirs pruned)\\n\\n`);\n\n const cutoffMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;\n const total = new SweepCount();\n for (const base of bases) total.add(this.sweeper.sweep(base, cutoffMs, this.reporter(base)));\n\n if (total.empty) {\n process.stdout.write(` ✅ Nothing older than ${RETENTION_DAYS} days; no empty directories\\n`);\n } else {\n const fileWord = total.files === 1 ? 'file' : 'files';\n const dirWord = total.dirs === 1 ? 'directory' : 'directories';\n process.stdout.write(`\\n ✅ Reaped ${total.files} old ${fileWord} and pruned ${total.dirs} empty ${dirWord}\\n`);\n }\n\n process.stdout.write('\\n' + SEP + '\\n');\n }\n\n /**\n * The per-worktree roots that exist, de-duplicated. `local()` and `aiWritable()` are the SAME path\n * in the primary clone — sweeping it twice would double-count and print every removal twice — and\n * two different paths in a linked worktree, where BOTH need reaping or `pr-review/` accumulates in\n * the worktree forever.\n */\n private sweepBases(repoRoot: string): string[] {\n const candidates = [this.dotDir.local(repoRoot), this.dotDir.aiWritable(repoRoot)];\n const seen = new Set<string>();\n return candidates.filter((base: string): boolean => {\n if (seen.has(base) || !fs.existsSync(base)) return false;\n seen.add(base);\n return true;\n });\n }\n\n // One line per removal, relative to the root it came from, so the two roots read as one report.\n private reporter(base: string): (removed: string, isDir: boolean) => void {\n return (removed: string, isDir: boolean): void => {\n const label = isDir ? 'dir: ' : 'file: ';\n process.stdout.write(` 🗑️ ${label} ${path.relative(base, removed)}${isDir ? '/' : ''}\\n`);\n };\n }\n}\n"]}