gentle-pi 0.1.19 → 0.1.21

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 (2) hide show
  1. package/extensions/sdd-init.ts +513 -113
  2. package/package.json +1 -1
@@ -5,11 +5,11 @@ import {
5
5
  readdirSync,
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
- import { basename, dirname, join } from "node:path";
8
+ import { basename, dirname, join, relative } from "node:path";
9
9
  type ExtensionAPI = any;
10
10
 
11
11
  const CONFIG_REL_PATH = "openspec/config.yaml";
12
- const MAX_SCAN_FILES = 5_000;
12
+ const MAX_SCAN_FILES = 20_000;
13
13
  const IGNORED_DIRS = new Set([
14
14
  ".git",
15
15
  ".hg",
@@ -36,21 +36,32 @@ interface PackageJson {
36
36
  peerDependencies?: Record<string, string>;
37
37
  }
38
38
 
39
+ interface CommandInfo {
40
+ scope: string;
41
+ command: string;
42
+ framework: string;
43
+ }
44
+
39
45
  interface Detection {
40
46
  projectName: string;
41
47
  stack: string[];
42
- packageManager?: string;
48
+ packageManagers: string[];
43
49
  markers: string[];
50
+ evidence: string[];
44
51
  testCommand?: string;
45
52
  testFramework?: string;
46
53
  coverageCommand?: string;
47
54
  lintCommand?: string;
48
55
  typecheckCommand?: string;
49
56
  formatCommand?: string;
50
- testLayers: {
51
- unit?: string;
52
- integration?: string;
53
- e2e?: string;
57
+ commands: {
58
+ unit: CommandInfo[];
59
+ integration: CommandInfo[];
60
+ e2e: CommandInfo[];
61
+ coverage: CommandInfo[];
62
+ lint: CommandInfo[];
63
+ typecheck: CommandInfo[];
64
+ format: CommandInfo[];
54
65
  };
55
66
  }
56
67
 
@@ -94,7 +105,7 @@ function walkProject(cwd: string): string[] {
94
105
  if (!IGNORED_DIRS.has(entry.name)) stack.push(join(dir, entry.name));
95
106
  continue;
96
107
  }
97
- if (entry.isFile()) out.push(join(dir, entry.name).slice(cwd.length + 1));
108
+ if (entry.isFile()) out.push(relative(cwd, join(dir, entry.name)));
98
109
  }
99
110
  }
100
111
  return out.sort();
@@ -108,15 +119,24 @@ function deps(pkg: PackageJson | undefined): Set<string> {
108
119
  ]);
109
120
  }
110
121
 
111
- function detectPackageManager(cwd: string): string | undefined {
112
- if (hasFile(cwd, "pnpm-lock.yaml")) return "pnpm";
113
- if (hasFile(cwd, "yarn.lock")) return "yarn";
114
- if (hasFile(cwd, "bun.lockb") || hasFile(cwd, "bun.lock")) return "bun";
115
- if (hasFile(cwd, "package-lock.json")) return "npm";
116
- if (hasFile(cwd, "package.json")) return "npm";
122
+ function detectPackageManagerAt(
123
+ cwd: string,
124
+ relDir: string,
125
+ ): string | undefined {
126
+ const base = join(cwd, relDir);
127
+ if (existsSync(join(base, "pnpm-lock.yaml"))) return "pnpm";
128
+ if (existsSync(join(base, "yarn.lock"))) return "yarn";
129
+ if (existsSync(join(base, "bun.lockb")) || existsSync(join(base, "bun.lock")))
130
+ return "bun";
131
+ if (existsSync(join(base, "package-lock.json"))) return "npm";
132
+ if (existsSync(join(base, "package.json"))) return "npm";
117
133
  return undefined;
118
134
  }
119
135
 
136
+ function commandInScope(scope: string, command: string): string {
137
+ return scope === "." ? command : `cd ${scope} && ${command}`;
138
+ }
139
+
120
140
  function runScript(pm: string | undefined, script: string): string {
121
141
  if (pm === "yarn") return `yarn ${script}`;
122
142
  if (pm === "bun") return `bun run ${script}`;
@@ -127,117 +147,369 @@ function scriptCommand(
127
147
  pm: string | undefined,
128
148
  scripts: Record<string, string> | undefined,
129
149
  candidates: string[],
130
- ): string | undefined {
150
+ ): { name: string; command: string } | undefined {
131
151
  if (!scripts) return undefined;
132
152
  for (const name of candidates) {
133
- if (scripts[name])
134
- return name === "test" && pm !== "bun"
153
+ if (!scripts[name]) continue;
154
+ const command =
155
+ name === "test" && pm !== "bun"
135
156
  ? `${pm ?? "npm"} test`
136
157
  : runScript(pm, name);
158
+ return { name, command };
137
159
  }
138
160
  return undefined;
139
161
  }
140
162
 
141
- function detectNode(cwd: string, files: string[], detection: Detection): void {
142
- const pkg = readJson<PackageJson>(join(cwd, "package.json"));
163
+ function addUnique(list: CommandInfo[], command: CommandInfo): void {
143
164
  if (
144
- !pkg &&
145
- !files.some(
146
- (f) =>
147
- f.endsWith(".ts") ||
148
- f.endsWith(".tsx") ||
149
- f.endsWith(".js") ||
150
- f.endsWith(".jsx"),
165
+ list.some(
166
+ (item) =>
167
+ item.scope === command.scope && item.command === command.command,
151
168
  )
152
169
  )
153
170
  return;
154
- const pm = detectPackageManager(cwd);
155
- detection.packageManager = pm;
156
- detection.stack.push(
157
- pkg?.type === "module" ? "Node.js/TypeScript ESM" : "Node.js/TypeScript",
171
+ list.push(command);
172
+ }
173
+
174
+ function addMarker(detection: Detection, marker: string): void {
175
+ if (!detection.markers.includes(marker)) detection.markers.push(marker);
176
+ }
177
+
178
+ function addStack(detection: Detection, stack: string): void {
179
+ if (!detection.stack.includes(stack)) detection.stack.push(stack);
180
+ }
181
+
182
+ function addEvidence(detection: Detection, evidence: string): void {
183
+ if (!detection.evidence.includes(evidence)) detection.evidence.push(evidence);
184
+ }
185
+
186
+ interface GenericHint {
187
+ marker: string;
188
+ stack: string;
189
+ testCommand?: string;
190
+ framework?: string;
191
+ }
192
+
193
+ const GENERIC_HINTS: GenericHint[] = [
194
+ {
195
+ marker: "mix.exs",
196
+ stack: "Elixir",
197
+ testCommand: "mix test",
198
+ framework: "ExUnit",
199
+ },
200
+ {
201
+ marker: "rebar.config",
202
+ stack: "Erlang",
203
+ testCommand: "rebar3 eunit",
204
+ framework: "EUnit",
205
+ },
206
+ {
207
+ marker: "gleam.toml",
208
+ stack: "Gleam",
209
+ testCommand: "gleam test",
210
+ framework: "gleam test",
211
+ },
212
+ {
213
+ marker: "deno.json",
214
+ stack: "Deno",
215
+ testCommand: "deno test",
216
+ framework: "deno test",
217
+ },
218
+ {
219
+ marker: "deno.jsonc",
220
+ stack: "Deno",
221
+ testCommand: "deno test",
222
+ framework: "deno test",
223
+ },
224
+ {
225
+ marker: "Gemfile",
226
+ stack: "Ruby",
227
+ testCommand: "bundle exec rake test",
228
+ framework: "Ruby test task",
229
+ },
230
+ {
231
+ marker: "composer.json",
232
+ stack: "PHP",
233
+ testCommand: "composer test",
234
+ framework: "Composer test script",
235
+ },
236
+ {
237
+ marker: "pom.xml",
238
+ stack: "Java/Maven",
239
+ testCommand: "mvn test",
240
+ framework: "Maven test",
241
+ },
242
+ {
243
+ marker: "build.gradle",
244
+ stack: "Java/Gradle",
245
+ testCommand: "./gradlew test",
246
+ framework: "Gradle test",
247
+ },
248
+ {
249
+ marker: "build.gradle.kts",
250
+ stack: "Java/Kotlin Gradle",
251
+ testCommand: "./gradlew test",
252
+ framework: "Gradle test",
253
+ },
254
+ {
255
+ marker: "pubspec.yaml",
256
+ stack: "Dart/Flutter",
257
+ testCommand: "dart test",
258
+ framework: "Dart test",
259
+ },
260
+ {
261
+ marker: "dune-project",
262
+ stack: "OCaml",
263
+ testCommand: "dune runtest",
264
+ framework: "Dune runtest",
265
+ },
266
+ {
267
+ marker: "shard.yml",
268
+ stack: "Crystal",
269
+ testCommand: "crystal spec",
270
+ framework: "Crystal spec",
271
+ },
272
+ {
273
+ marker: "stack.yaml",
274
+ stack: "Haskell",
275
+ testCommand: "stack test",
276
+ framework: "Stack test",
277
+ },
278
+ ];
279
+
280
+ function setPrimaryTest(
281
+ detection: Detection,
282
+ command: CommandInfo,
283
+ prefer = false,
284
+ ): void {
285
+ if (!detection.testCommand || prefer) {
286
+ detection.testCommand = command.command;
287
+ detection.testFramework = command.framework;
288
+ }
289
+ }
290
+
291
+ function packageDirs(files: string[]): string[] {
292
+ const dirs = files
293
+ .filter((file) => basename(file) === "package.json")
294
+ .map((file) => (dirname(file) === "." ? "." : dirname(file)));
295
+ return [...new Set(dirs)].sort(
296
+ (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b),
297
+ );
298
+ }
299
+
300
+ function filesInScope(files: string[], scope: string): string[] {
301
+ if (scope === ".") return files;
302
+ const prefix = `${scope}/`;
303
+ return files
304
+ .filter((file) => file.startsWith(prefix))
305
+ .map((file) => file.slice(prefix.length));
306
+ }
307
+
308
+ function detectNodePackage(
309
+ cwd: string,
310
+ files: string[],
311
+ scope: string,
312
+ detection: Detection,
313
+ ): void {
314
+ const pkg = readJson<PackageJson>(join(cwd, scope, "package.json"));
315
+ if (!pkg) return;
316
+ const scopedFiles = filesInScope(files, scope);
317
+ const pm = detectPackageManagerAt(cwd, scope);
318
+ if (pm && !detection.packageManagers.includes(pm))
319
+ detection.packageManagers.push(pm);
320
+ addStack(
321
+ detection,
322
+ pkg.type === "module" ? "Node.js/TypeScript ESM" : "Node.js/TypeScript",
323
+ );
324
+ if (pkg.name && detection.projectName === basename(cwd))
325
+ detection.projectName = pkg.name;
326
+ addMarker(
327
+ detection,
328
+ scope === "." ? "package.json" : `${scope}/package.json`,
158
329
  );
159
- if (pkg?.name) detection.projectName = pkg.name;
160
- if (hasFile(cwd, "package.json")) detection.markers.push("package.json");
161
- if (hasFile(cwd, "tsconfig.json")) detection.markers.push("tsconfig.json");
162
- if (pm) detection.markers.push(`${pm} package manager`);
330
+ if (existsSync(join(cwd, scope, "tsconfig.json")))
331
+ addMarker(detection, `${scope === "." ? "" : `${scope}/`}tsconfig.json`);
332
+ if (pm)
333
+ addMarker(
334
+ detection,
335
+ `${scope === "." ? "" : `${scope}/`}${pm} package manager`,
336
+ );
163
337
 
164
338
  const allDeps = deps(pkg);
165
- if (allDeps.has("react")) detection.stack.push("React");
166
- if (allDeps.has("next")) detection.stack.push("Next.js");
167
- if (allDeps.has("vue")) detection.stack.push("Vue");
168
- if (allDeps.has("svelte")) detection.stack.push("Svelte");
339
+ if (allDeps.has("react")) addStack(detection, "React");
340
+ if (allDeps.has("next")) addStack(detection, "Next.js");
341
+ if (allDeps.has("vue")) addStack(detection, "Vue");
342
+ if (allDeps.has("svelte")) addStack(detection, "Svelte");
169
343
  if (allDeps.has("@earendil-works/pi-coding-agent"))
170
- detection.stack.push("Pi extension package");
344
+ addStack(detection, "Pi extension package");
171
345
 
172
- detection.testCommand = scriptCommand(pm, pkg?.scripts, [
346
+ let unit = scriptCommand(pm, pkg.scripts, [
347
+ "test:run",
173
348
  "test",
174
349
  "vitest",
175
350
  "jest",
176
351
  "unit",
177
352
  ]);
178
- if (!detection.testCommand) {
353
+ if (!unit) {
179
354
  if (
180
355
  allDeps.has("vitest") ||
181
- files.some((f) => /^vitest\.config\./.test(basename(f)))
182
- )
183
- detection.testCommand = runScript(pm, "vitest");
184
- else if (
356
+ scopedFiles.some((file) => /^vitest\.config\./.test(basename(file)))
357
+ ) {
358
+ unit = { name: "vitest", command: runScript(pm, "vitest") };
359
+ } else if (
185
360
  allDeps.has("jest") ||
186
- files.some((f) => /^jest\.config\./.test(basename(f)))
187
- )
188
- detection.testCommand = runScript(pm, "jest");
361
+ scopedFiles.some((file) => /^jest\.config\./.test(basename(file)))
362
+ ) {
363
+ unit = { name: "jest", command: runScript(pm, "jest") };
364
+ }
365
+ }
366
+ if (unit) {
367
+ const framework =
368
+ allDeps.has("vitest") || /vitest/i.test(unit.command)
369
+ ? "Vitest"
370
+ : allDeps.has("jest") || /jest/i.test(unit.command)
371
+ ? "Jest"
372
+ : "package script";
373
+ const info = {
374
+ scope,
375
+ command: commandInScope(scope, unit.command),
376
+ framework,
377
+ };
378
+ addUnique(detection.commands.unit, info);
379
+ setPrimaryTest(detection, info);
189
380
  }
190
- if (allDeps.has("vitest")) detection.testFramework = "Vitest";
191
- else if (allDeps.has("jest")) detection.testFramework = "Jest";
192
- else if (detection.testCommand) detection.testFramework = "package script";
193
381
 
194
- detection.coverageCommand = scriptCommand(pm, pkg?.scripts, [
195
- "coverage",
382
+ const integration = scriptCommand(pm, pkg.scripts, [
383
+ "test:integration",
384
+ "integration",
385
+ ]);
386
+ if (integration)
387
+ addUnique(detection.commands.integration, {
388
+ scope,
389
+ command: commandInScope(scope, integration.command),
390
+ framework: "package integration script",
391
+ });
392
+ if (allDeps.has("@testing-library/react") || allDeps.has("supertest")) {
393
+ const framework = allDeps.has("supertest")
394
+ ? "Supertest"
395
+ : "Testing Library";
396
+ if (unit)
397
+ addUnique(detection.commands.integration, {
398
+ scope,
399
+ command: commandInScope(scope, unit.command),
400
+ framework,
401
+ });
402
+ }
403
+
404
+ let e2e = scriptCommand(pm, pkg.scripts, [
405
+ "test:e2e",
406
+ "e2e",
407
+ "playwright",
408
+ "cypress",
409
+ ]);
410
+ if (!e2e) {
411
+ if (
412
+ allDeps.has("@playwright/test") ||
413
+ allDeps.has("playwright") ||
414
+ scopedFiles.some((file) => /^playwright\.config\./.test(basename(file)))
415
+ ) {
416
+ e2e = { name: "playwright", command: "npx playwright test" };
417
+ } else if (
418
+ allDeps.has("cypress") ||
419
+ scopedFiles.some((file) => /^cypress\.config\./.test(basename(file)))
420
+ ) {
421
+ e2e = { name: "cypress", command: "npx cypress run" };
422
+ }
423
+ }
424
+ if (e2e) {
425
+ const framework = /cypress/i.test(e2e.command) ? "Cypress" : "Playwright";
426
+ addUnique(detection.commands.e2e, {
427
+ scope,
428
+ command: commandInScope(scope, e2e.command),
429
+ framework,
430
+ });
431
+ }
432
+
433
+ const coverage = scriptCommand(pm, pkg.scripts, [
196
434
  "test:coverage",
435
+ "coverage",
197
436
  ]);
198
- detection.lintCommand = scriptCommand(pm, pkg?.scripts, [
437
+ if (coverage)
438
+ addUnique(detection.commands.coverage, {
439
+ scope,
440
+ command: commandInScope(scope, coverage.command),
441
+ framework: "coverage",
442
+ });
443
+ const lint = scriptCommand(pm, pkg.scripts, [
199
444
  "lint",
445
+ "lint:check",
200
446
  "check:lint",
201
447
  ]);
202
- detection.typecheckCommand = scriptCommand(pm, pkg?.scripts, [
448
+ if (lint)
449
+ addUnique(detection.commands.lint, {
450
+ scope,
451
+ command: commandInScope(scope, lint.command),
452
+ framework: "linter",
453
+ });
454
+ const typecheck = scriptCommand(pm, pkg.scripts, [
203
455
  "typecheck",
204
456
  "type-check",
205
457
  "check:types",
206
458
  ]);
207
- detection.formatCommand = scriptCommand(pm, pkg?.scripts, [
459
+ if (typecheck)
460
+ addUnique(detection.commands.typecheck, {
461
+ scope,
462
+ command: commandInScope(scope, typecheck.command),
463
+ framework: "type checker",
464
+ });
465
+ const format = scriptCommand(pm, pkg.scripts, [
208
466
  "format",
467
+ "format:check",
209
468
  "fmt",
210
469
  "prettier",
211
470
  ]);
471
+ if (format)
472
+ addUnique(detection.commands.format, {
473
+ scope,
474
+ command: commandInScope(scope, format.command),
475
+ framework: "formatter",
476
+ });
477
+ }
212
478
 
213
- if (detection.testFramework)
214
- detection.testLayers.unit = detection.testFramework;
215
- if (allDeps.has("@testing-library/react") || allDeps.has("supertest"))
216
- detection.testLayers.integration = "Testing Library / Supertest";
217
- if (allDeps.has("playwright") || allDeps.has("@playwright/test"))
218
- detection.testLayers.e2e = "Playwright";
219
- else if (allDeps.has("cypress")) detection.testLayers.e2e = "Cypress";
479
+ function detectNode(cwd: string, files: string[], detection: Detection): void {
480
+ const dirs = packageDirs(files);
481
+ if (dirs.length === 0 && files.some((file) => /\.[cm]?[tj]sx?$/.test(file)))
482
+ addStack(detection, "Node.js/TypeScript");
483
+ for (const dir of dirs) detectNodePackage(cwd, files, dir, detection);
220
484
  }
221
485
 
222
486
  function detectGo(cwd: string, files: string[], detection: Detection): void {
223
487
  if (!hasFile(cwd, "go.mod")) return;
224
- detection.stack.push("Go");
225
- detection.markers.push("go.mod");
226
- if (!detection.testCommand) detection.testCommand = "go test ./...";
227
- if (!detection.testFramework) detection.testFramework = "go test";
228
- detection.testLayers.unit ??= "go test";
229
- if (files.some((f) => f.endsWith("_test.go")))
230
- detection.testLayers.integration ??= "Go integration tests where present";
231
- detection.coverageCommand ??= "go test -cover ./...";
488
+ addStack(detection, "Go");
489
+ addMarker(detection, "go.mod");
490
+ const info = { scope: ".", command: "go test ./...", framework: "go test" };
491
+ addUnique(detection.commands.unit, info);
492
+ setPrimaryTest(detection, info);
493
+ if (files.some((file) => file.endsWith("_test.go")))
494
+ addUnique(detection.commands.integration, {
495
+ scope: ".",
496
+ command: "go test ./...",
497
+ framework: "Go integration tests where present",
498
+ });
499
+ addUnique(detection.commands.coverage, {
500
+ scope: ".",
501
+ command: "go test -cover ./...",
502
+ framework: "go coverage",
503
+ });
232
504
  }
233
505
 
234
506
  function detectRust(cwd: string, detection: Detection): void {
235
507
  if (!hasFile(cwd, "Cargo.toml")) return;
236
- detection.stack.push("Rust");
237
- detection.markers.push("Cargo.toml");
238
- detection.testCommand ??= "cargo test";
239
- detection.testFramework ??= "cargo test";
240
- detection.testLayers.unit ??= "cargo test";
508
+ addStack(detection, "Rust");
509
+ addMarker(detection, "Cargo.toml");
510
+ const info = { scope: ".", command: "cargo test", framework: "cargo test" };
511
+ addUnique(detection.commands.unit, info);
512
+ setPrimaryTest(detection, info);
241
513
  }
242
514
 
243
515
  function detectPython(
@@ -249,41 +521,102 @@ function detectPython(
249
521
  hasFile(cwd, "pyproject.toml") ||
250
522
  hasFile(cwd, "requirements.txt") ||
251
523
  hasFile(cwd, "pytest.ini") ||
252
- files.some((f) => f.endsWith(".py"));
524
+ files.some((file) => file.endsWith(".py"));
253
525
  if (!hasPython) return;
254
- detection.stack.push("Python");
526
+ addStack(detection, "Python");
255
527
  for (const marker of ["pyproject.toml", "requirements.txt", "pytest.ini"]) {
256
- if (hasFile(cwd, marker)) detection.markers.push(marker);
528
+ if (hasFile(cwd, marker)) addMarker(detection, marker);
257
529
  }
258
530
  if (
259
- !detection.testCommand &&
260
- (hasFile(cwd, "pytest.ini") ||
261
- files.some((f) => f.startsWith("tests/") || f.endsWith("_test.py")))
531
+ files.some(
532
+ (file) =>
533
+ file.startsWith("tests/") ||
534
+ file.endsWith("_test.py") ||
535
+ basename(file) === "conftest.py",
536
+ )
262
537
  ) {
263
- detection.testCommand = "pytest";
264
- detection.testFramework = "pytest";
265
- detection.testLayers.unit = "pytest";
538
+ const info = { scope: ".", command: "pytest", framework: "pytest" };
539
+ addUnique(detection.commands.unit, info);
540
+ setPrimaryTest(detection, info);
266
541
  }
267
542
  }
268
543
 
544
+ function detectGenericHints(
545
+ cwd: string,
546
+ files: string[],
547
+ detection: Detection,
548
+ ): void {
549
+ for (const hint of GENERIC_HINTS) {
550
+ if (!hasFile(cwd, hint.marker)) continue;
551
+ addStack(detection, hint.stack);
552
+ addMarker(detection, hint.marker);
553
+ addEvidence(
554
+ detection,
555
+ `${hint.stack} manifest detected via ${hint.marker}`,
556
+ );
557
+ if (hint.testCommand && hint.framework) {
558
+ const info = {
559
+ scope: ".",
560
+ command: hint.testCommand,
561
+ framework: hint.framework,
562
+ };
563
+ addUnique(detection.commands.unit, info);
564
+ setPrimaryTest(detection, info);
565
+ }
566
+ }
567
+
568
+ const testFiles = files.filter(
569
+ (file) =>
570
+ /(^|\/)(test|tests|spec|specs)(\/|$)/i.test(file) ||
571
+ /[._-](test|spec)\.[^/]+$/i.test(file),
572
+ );
573
+ if (testFiles.length > 0) {
574
+ addEvidence(
575
+ detection,
576
+ `Test-like files detected (${testFiles.length}); examples: ${testFiles.slice(0, 5).join(", ")}`,
577
+ );
578
+ }
579
+ if (detection.stack.length === 0)
580
+ addStack(detection, "Unclassified software project");
581
+ }
582
+
269
583
  function detectMakefile(cwd: string, detection: Detection): void {
270
- const makefile = ["Makefile", "makefile"].find((f) => hasFile(cwd, f));
584
+ const makefile = ["Makefile", "makefile"].find((file) => hasFile(cwd, file));
271
585
  if (!makefile) return;
272
- detection.markers.push(makefile);
586
+ addMarker(detection, makefile);
273
587
  let content = "";
274
588
  try {
275
589
  content = readFileSync(join(cwd, makefile), "utf8");
276
590
  } catch {
277
591
  return;
278
592
  }
279
- if (!detection.testCommand && /^test:/m.test(content))
280
- detection.testCommand = "make test";
281
- if (!detection.lintCommand && /^lint:/m.test(content))
282
- detection.lintCommand = "make lint";
283
- if (!detection.formatCommand && /^(fmt|format):/m.test(content))
284
- detection.formatCommand = /^fmt:/m.test(content)
285
- ? "make fmt"
286
- : "make format";
593
+ if (/^test:/m.test(content)) {
594
+ const info = {
595
+ scope: ".",
596
+ command: "make test",
597
+ framework: "pytest via Makefile",
598
+ };
599
+ addUnique(detection.commands.unit, info);
600
+ setPrimaryTest(detection, info, true);
601
+ }
602
+ if (/^coverage:/m.test(content))
603
+ addUnique(detection.commands.coverage, {
604
+ scope: ".",
605
+ command: "make coverage",
606
+ framework: "coverage",
607
+ });
608
+ if (/^lint:/m.test(content))
609
+ addUnique(detection.commands.lint, {
610
+ scope: ".",
611
+ command: "make lint",
612
+ framework: "linter",
613
+ });
614
+ if (/^(fmt|format):/m.test(content))
615
+ addUnique(detection.commands.format, {
616
+ scope: ".",
617
+ command: /^fmt:/m.test(content) ? "make fmt" : "make format",
618
+ framework: "formatter",
619
+ });
287
620
  }
288
621
 
289
622
  function detectProject(cwd: string): Detection {
@@ -291,42 +624,93 @@ function detectProject(cwd: string): Detection {
291
624
  const detection: Detection = {
292
625
  projectName: basename(cwd),
293
626
  stack: [],
627
+ packageManagers: [],
294
628
  markers: [],
295
- testLayers: {},
629
+ evidence: [],
630
+ commands: {
631
+ unit: [],
632
+ integration: [],
633
+ e2e: [],
634
+ coverage: [],
635
+ lint: [],
636
+ typecheck: [],
637
+ format: [],
638
+ },
296
639
  };
297
640
  detectNode(cwd, files, detection);
298
641
  detectGo(cwd, files, detection);
299
642
  detectRust(cwd, detection);
300
643
  detectPython(cwd, files, detection);
644
+ detectGenericHints(cwd, files, detection);
301
645
  detectMakefile(cwd, detection);
302
- if (hasFile(cwd, ".github/workflows"))
303
- detection.markers.push("GitHub Actions");
304
- detection.stack = [...new Set(detection.stack)];
305
- detection.markers = [...new Set(detection.markers)];
646
+ if (hasFile(cwd, ".github/workflows")) addMarker(detection, "GitHub Actions");
647
+ detection.coverageCommand = detection.commands.coverage[0]?.command;
648
+ detection.lintCommand = detection.commands.lint[0]?.command;
649
+ detection.typecheckCommand = detection.commands.typecheck[0]?.command;
650
+ detection.formatCommand = detection.commands.format[0]?.command;
306
651
  return detection;
307
652
  }
308
653
 
654
+ function commandSummary(commands: CommandInfo[]): string {
655
+ if (commands.length === 0) return "none";
656
+ return commands
657
+ .map((command) => `${command.framework} (${command.command})`)
658
+ .join("; ");
659
+ }
660
+
309
661
  function renderContext(detection: Detection): string {
310
662
  const lines = [
311
663
  `${detection.projectName} is a ${detection.stack.length > 0 ? detection.stack.join(", ") : "software"} project.`,
312
664
  `Detected markers: ${detection.markers.length > 0 ? detection.markers.join(", ") : "none"}.`,
313
665
  ];
314
- if (detection.packageManager)
315
- lines.push(`Package manager: ${detection.packageManager}.`);
666
+ if (detection.packageManagers.length > 0)
667
+ lines.push(`Package managers: ${detection.packageManagers.join(", ")}.`);
668
+ if (detection.evidence.length > 0)
669
+ lines.push(`Additional evidence: ${detection.evidence.join("; ")}.`);
316
670
  if (detection.testCommand)
317
671
  lines.push(`Primary test command: ${detection.testCommand}.`);
318
672
  else
319
673
  lines.push(
320
674
  "No reliable test runner was detected; verify testing manually before enabling strict TDD.",
321
675
  );
676
+ lines.push(`Unit tests: ${commandSummary(detection.commands.unit)}.`);
677
+ lines.push(
678
+ `Integration tests: ${commandSummary(detection.commands.integration)}.`,
679
+ );
680
+ lines.push(`E2E tests: ${commandSummary(detection.commands.e2e)}.`);
322
681
  return lines.join("\n");
323
682
  }
324
683
 
684
+ function pushCommandList(
685
+ lines: string[],
686
+ indent: string,
687
+ commands: CommandInfo[],
688
+ ): void {
689
+ if (commands.length === 0) {
690
+ lines.push(`${indent}[]`);
691
+ return;
692
+ }
693
+ for (const command of commands) {
694
+ lines.push(`${indent}- scope: ${yamlString(command.scope)}`);
695
+ lines.push(`${indent} command: ${yamlString(command.command)}`);
696
+ lines.push(`${indent} framework: ${yamlString(command.framework)}`);
697
+ }
698
+ }
699
+
325
700
  function renderConfig(detection: Detection): string {
326
701
  const strictTdd = Boolean(detection.testCommand);
327
702
  const testCommand = detection.testCommand ?? "";
328
703
  const today = new Date().toISOString().slice(0, 10);
329
704
  const context = renderContext(detection);
705
+ const unitLayer = detection.commands.unit
706
+ .map((command) => command.framework)
707
+ .join(", ");
708
+ const integrationLayer = detection.commands.integration
709
+ .map((command) => command.framework)
710
+ .join(", ");
711
+ const e2eLayer = detection.commands.e2e
712
+ .map((command) => command.framework)
713
+ .join(", ");
330
714
  const lines = [
331
715
  `strict_tdd: ${strictTdd}`,
332
716
  "context: |",
@@ -350,17 +734,32 @@ function renderConfig(detection: Detection): string {
350
734
  ` command: ${yamlString(testCommand)}`,
351
735
  ` framework: ${yamlString(detection.testFramework ?? "")}`,
352
736
  " layers:",
353
- ` unit: ${yamlString(detection.testLayers.unit ?? "")}`,
354
- ` integration: ${yamlString(detection.testLayers.integration ?? "")}`,
355
- ` e2e: ${yamlString(detection.testLayers.e2e ?? "")}`,
356
- " coverage:",
357
- ` command: ${yamlString(detection.coverageCommand ?? "")}`,
358
- "quality:",
359
- ` lint: ${yamlString(detection.lintCommand ?? "")}`,
360
- ` typecheck: ${yamlString(detection.typecheckCommand ?? "")}`,
361
- ` format: ${yamlString(detection.formatCommand ?? "")}`,
362
- "",
737
+ ` unit: ${yamlString(unitLayer)}`,
738
+ ` integration: ${yamlString(integrationLayer)}`,
739
+ ` e2e: ${yamlString(e2eLayer)}`,
740
+ " commands:",
741
+ " unit:",
363
742
  ];
743
+ pushCommandList(lines, " ", detection.commands.unit);
744
+ lines.push(" integration:");
745
+ pushCommandList(lines, " ", detection.commands.integration);
746
+ lines.push(" e2e:");
747
+ pushCommandList(lines, " ", detection.commands.e2e);
748
+ lines.push(" coverage:");
749
+ lines.push(` command: ${yamlString(detection.coverageCommand ?? "")}`);
750
+ lines.push(" commands:");
751
+ pushCommandList(lines, " ", detection.commands.coverage);
752
+ lines.push("quality:");
753
+ lines.push(` lint: ${yamlString(detection.lintCommand ?? "")}`);
754
+ lines.push(" lint_commands:");
755
+ pushCommandList(lines, " ", detection.commands.lint);
756
+ lines.push(` typecheck: ${yamlString(detection.typecheckCommand ?? "")}`);
757
+ lines.push(" typecheck_commands:");
758
+ pushCommandList(lines, " ", detection.commands.typecheck);
759
+ lines.push(` format: ${yamlString(detection.formatCommand ?? "")}`);
760
+ lines.push(" format_commands:");
761
+ pushCommandList(lines, " ", detection.commands.format);
762
+ lines.push("");
364
763
  return lines.join("\n");
365
764
  }
366
765
 
@@ -391,8 +790,9 @@ export default function (pi: ExtensionAPI) {
391
790
  const testSummary = detection.testCommand
392
791
  ? `strict TDD enabled with \`${detection.testCommand}\``
393
792
  : "strict TDD disabled because no test runner was detected";
793
+ const layerSummary = `unit: ${detection.commands.unit.length}, integration: ${detection.commands.integration.length}, e2e: ${detection.commands.e2e.length}`;
394
794
  ctx.ui.notify(
395
- `Wrote ${CONFIG_REL_PATH}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}.`,
795
+ `Wrote ${CONFIG_REL_PATH}: detected ${detection.stack.join(", ") || "project"}; ${testSummary}; tests found: ${layerSummary}.`,
396
796
  "info",
397
797
  );
398
798
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",