@dennisrongo/dsh-todo 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -48,8 +48,8 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
48
48
  var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
49
49
 
50
50
  // src/index.ts
51
- import { readFileSync, renameSync, existsSync } from "node:fs";
52
- import { isAbsolute, join as join2, resolve as resolve2 } from "node:path";
51
+ import { readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, existsSync, unlinkSync } from "node:fs";
52
+ import { isAbsolute, join as join3, resolve as resolve2 } from "node:path";
53
53
  import { Service } from "@deepseek-ai/cordis";
54
54
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
55
55
  import { z } from "zod";
@@ -92,6 +92,14 @@ var MAX_TEXT = 500;
92
92
  var MAX_DESC = 5e3;
93
93
  var MAX_LABEL = 60;
94
94
  var MAX_ITEMS = 1e3;
95
+ var SUGGESTIONS_DIR = ".dsh";
96
+ var SUGGESTIONS_FILE = `${SUGGESTIONS_DIR}/suggestions.json`;
97
+ var SUGGESTIONS_FILE_RE = /^suggestions(-[a-z0-9]+)?\.json$/;
98
+ function suggestionsFileFor(runId) {
99
+ return `${SUGGESTIONS_DIR}/suggestions-${runId}.json`;
100
+ }
101
+ __name(suggestionsFileFor, "suggestionsFileFor");
102
+ var MAX_SUGGESTIONS = 12;
95
103
 
96
104
  // src/db.ts
97
105
  var DOT_DSH = ".dsh";
@@ -216,6 +224,337 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
216
224
  }
217
225
  __name(writeList, "writeList");
218
226
 
227
+ // src/scan.ts
228
+ import { readdirSync, readFileSync, statSync } from "node:fs";
229
+ import { join as join2, relative, sep } from "node:path";
230
+ var DIGEST_BYTE_CAP = 24e3;
231
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
232
+ ".git",
233
+ ".hg",
234
+ ".svn",
235
+ "node_modules",
236
+ "bower_components",
237
+ "jspm_packages",
238
+ "lib",
239
+ "dist",
240
+ "build",
241
+ "out",
242
+ "coverage",
243
+ ".next",
244
+ ".nuxt",
245
+ ".svelte-kit",
246
+ ".output",
247
+ ".parcel-cache",
248
+ ".turbo",
249
+ ".cache",
250
+ ".venv",
251
+ "venv",
252
+ "__pycache__",
253
+ ".tox",
254
+ ".mypy_cache",
255
+ ".pytest_cache",
256
+ "target",
257
+ "vendor",
258
+ "vendored",
259
+ "third_party",
260
+ "thirdparty",
261
+ "generated",
262
+ "__generated__",
263
+ "Pods",
264
+ "Carthage",
265
+ "DerivedData"
266
+ ]);
267
+ var SOURCE_EXT = /* @__PURE__ */ new Set([
268
+ ".ts",
269
+ ".tsx",
270
+ ".js",
271
+ ".jsx",
272
+ ".mjs",
273
+ ".cjs",
274
+ ".py",
275
+ ".go",
276
+ ".rs",
277
+ ".java",
278
+ ".rb",
279
+ ".php",
280
+ ".cs",
281
+ ".swift",
282
+ ".kt",
283
+ ".scala",
284
+ ".sh"
285
+ ]);
286
+ var MAX_FILES_WALKED = 4e3;
287
+ var MAX_TREE_ENTRIES = 300;
288
+ var MAX_COMMENTS = 80;
289
+ var MAX_UNTESTED = 40;
290
+ var MAX_COMMENT_LINE = 160;
291
+ var README_BYTES = 4e3;
292
+ var MANIFEST_BYTES = 2e3;
293
+ var MAX_DEPTH = 8;
294
+ var SCAN_CEILING_FACTOR = 10;
295
+ var MAX_FILES_READ = 400;
296
+ var MAX_READ_BYTES = 2 * 1024 * 1024;
297
+ function posix(path) {
298
+ return path.split(sep).join("/");
299
+ }
300
+ __name(posix, "posix");
301
+ function walk(root) {
302
+ const files = [];
303
+ let truncated = false;
304
+ const visit = /* @__PURE__ */ __name((dir, depth) => {
305
+ if (depth > MAX_DEPTH || files.length >= MAX_FILES_WALKED) {
306
+ truncated = true;
307
+ return;
308
+ }
309
+ let entries;
310
+ try {
311
+ entries = readdirSync(dir, { withFileTypes: true });
312
+ } catch {
313
+ return;
314
+ }
315
+ for (const entry of entries) {
316
+ if (files.length >= MAX_FILES_WALKED) {
317
+ truncated = true;
318
+ return;
319
+ }
320
+ if (entry.isDirectory()) {
321
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
322
+ visit(join2(dir, entry.name), depth + 1);
323
+ } else if (entry.isFile()) {
324
+ files.push(posix(relative(root, join2(dir, entry.name))));
325
+ }
326
+ }
327
+ }, "visit");
328
+ try {
329
+ if (!statSync(root).isDirectory()) return { files: [], truncated: false };
330
+ } catch {
331
+ return { files: [], truncated: false };
332
+ }
333
+ visit(root, 0);
334
+ return { files, truncated };
335
+ }
336
+ __name(walk, "walk");
337
+ function readText(path, limit = Number.MAX_SAFE_INTEGER) {
338
+ let raw;
339
+ try {
340
+ if (statSync(path).size > MAX_READ_BYTES) return "";
341
+ raw = readFileSync(path, "utf8");
342
+ } catch {
343
+ return "";
344
+ }
345
+ if (raw.includes("\0")) return "";
346
+ return raw.length > limit ? raw.slice(0, limit) : raw;
347
+ }
348
+ __name(readText, "readText");
349
+ function skippedForSize(path) {
350
+ try {
351
+ return statSync(path).size > MAX_READ_BYTES;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+ __name(skippedForSize, "skippedForSize");
357
+ var COMMENT_RE = /(?:^|\s)(?:\/\/|#|\/\*|\*)\s*(TODO|FIXME|HACK)\b[:\s]?(.*)$/;
358
+ function collectComments(root, files) {
359
+ const ceiling = MAX_COMMENTS * SCAN_CEILING_FACTOR;
360
+ const kept = [];
361
+ let total = 0;
362
+ let read = 0;
363
+ let skipped = 0;
364
+ let bounded = false;
365
+ for (const rel of files) {
366
+ const dot = rel.lastIndexOf(".");
367
+ if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
368
+ if (total >= ceiling || read >= MAX_FILES_READ) {
369
+ bounded = true;
370
+ break;
371
+ }
372
+ const full = join2(root, rel);
373
+ if (skippedForSize(full)) {
374
+ skipped += 1;
375
+ continue;
376
+ }
377
+ read += 1;
378
+ const text = readText(full);
379
+ if (text === "") continue;
380
+ const lines = text.split(/\r?\n/);
381
+ for (let i = 0; i < lines.length; i += 1) {
382
+ const match = COMMENT_RE.exec(lines[i]);
383
+ if (match === null) continue;
384
+ total += 1;
385
+ if (kept.length >= MAX_COMMENTS) continue;
386
+ const body = match[2].trim().slice(0, MAX_COMMENT_LINE);
387
+ kept.push(`${rel}:${i + 1} ${match[1]} ${body}`.trimEnd());
388
+ }
389
+ }
390
+ return { kept, total, bounded, skippedForSize: skipped };
391
+ }
392
+ __name(collectComments, "collectComments");
393
+ function hasTest(base, testNames) {
394
+ return testNames.has(`${base}.test`) || testNames.has(`${base}.spec`) || testNames.has(`test_${base}`) || testNames.has(`${base}_test`) || testNames.has(base);
395
+ }
396
+ __name(hasTest, "hasTest");
397
+ function collectUntested(files) {
398
+ const testNames = /* @__PURE__ */ new Set();
399
+ for (const rel of files) {
400
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
401
+ const stem = name.replace(/\.[^.]+$/, "");
402
+ if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) {
403
+ testNames.add(stem);
404
+ testNames.add(stem.replace(/\.(test|spec)$/i, ""));
405
+ }
406
+ }
407
+ const ceiling = MAX_UNTESTED * SCAN_CEILING_FACTOR;
408
+ const kept = [];
409
+ let total = 0;
410
+ let bounded = false;
411
+ for (const rel of files) {
412
+ const dot = rel.lastIndexOf(".");
413
+ if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
414
+ if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) continue;
415
+ const stem = rel.slice(rel.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
416
+ if (/^(index|main|types|constants)$/i.test(stem)) continue;
417
+ if (hasTest(stem, testNames)) continue;
418
+ if (total >= ceiling) {
419
+ bounded = true;
420
+ break;
421
+ }
422
+ total += 1;
423
+ if (kept.length < MAX_UNTESTED) kept.push(rel);
424
+ }
425
+ return { kept, total, bounded, skippedForSize: 0 };
426
+ }
427
+ __name(collectUntested, "collectUntested");
428
+ function sectionHeader(title, total, kept, options = {}) {
429
+ const bound = options.bounded === true ? "+" : "";
430
+ const skipped = options.skippedForSize ?? 0;
431
+ const note = skipped > 0 ? ` (${skipped} file(s) too large to read)` : "";
432
+ const counts = kept < total || options.bounded === true ? `(${total}${bound} found, showing ${kept})` : `(${total})`;
433
+ return `### ${title} ${counts}${note}`;
434
+ }
435
+ __name(sectionHeader, "sectionHeader");
436
+ function fileHeader(name, text, limit) {
437
+ if (text.length < limit) return `### ${name}`;
438
+ return `### ${name} (clipped to first ${Math.round(limit / 1e3)} KB)`;
439
+ }
440
+ __name(fileHeader, "fileHeader");
441
+ function assemble(sections, walkTruncated) {
442
+ const parts = walkTruncated ? sections.concat(
443
+ "[walk truncated \u2014 this workspace is deeper or larger than one scan walks; files below the depth or count limit were never examined]"
444
+ ) : sections;
445
+ const joined = parts.join("\n\n");
446
+ if (joined.length <= DIGEST_BYTE_CAP) {
447
+ return { digest: joined, truncated: walkTruncated };
448
+ }
449
+ const marker = "\n\n[digest truncated \u2014 the workspace is larger than one scan can carry]";
450
+ return { digest: joined.slice(0, DIGEST_BYTE_CAP - marker.length) + marker, truncated: true };
451
+ }
452
+ __name(assemble, "assemble");
453
+ function buildDigest(root) {
454
+ const { files, truncated } = walk(root);
455
+ const sections = [];
456
+ let sectionTruncated = false;
457
+ const tree = files.slice(0, MAX_TREE_ENTRIES);
458
+ if (tree.length > 0) {
459
+ if (tree.length < files.length) sectionTruncated = true;
460
+ sections.push(`${sectionHeader("Files", files.length, tree.length)}
461
+ ${tree.join("\n")}`);
462
+ }
463
+ const readmeName = files.find((f) => /^readme(\.md|\.txt)?$/i.test(f));
464
+ if (readmeName !== void 0) {
465
+ const raw = readText(join2(root, readmeName), README_BYTES);
466
+ const text = raw.trim();
467
+ if (text !== "") {
468
+ if (raw.length >= README_BYTES) sectionTruncated = true;
469
+ sections.push(`${fileHeader(readmeName, raw, README_BYTES)}
470
+ ${text}`);
471
+ }
472
+ }
473
+ const manifest = files.find((f) => f === "package.json");
474
+ if (manifest !== void 0) {
475
+ const raw = readText(join2(root, manifest), MANIFEST_BYTES);
476
+ const text = raw.trim();
477
+ if (text !== "") {
478
+ if (raw.length >= MANIFEST_BYTES) sectionTruncated = true;
479
+ sections.push(`${fileHeader("package.json", raw, MANIFEST_BYTES)}
480
+ ${text}`);
481
+ }
482
+ }
483
+ const comments = collectComments(root, files);
484
+ if (comments.kept.length > 0 || comments.skippedForSize > 0) {
485
+ if (comments.kept.length < comments.total || comments.bounded || comments.skippedForSize > 0) sectionTruncated = true;
486
+ sections.push(
487
+ sectionHeader(
488
+ "Unresolved comments (TODO/FIXME/HACK)",
489
+ comments.total,
490
+ comments.kept.length,
491
+ { bounded: comments.bounded, skippedForSize: comments.skippedForSize }
492
+ ) + (comments.kept.length > 0 ? "\n" + comments.kept.join("\n") : "")
493
+ );
494
+ }
495
+ const untested = collectUntested(files);
496
+ if (untested.kept.length > 0) {
497
+ if (untested.kept.length < untested.total || untested.bounded) sectionTruncated = true;
498
+ sections.push(
499
+ sectionHeader(
500
+ "Untested modules (name-based hint, not a coverage run)",
501
+ untested.total,
502
+ untested.kept.length,
503
+ { bounded: untested.bounded }
504
+ ) + "\n" + untested.kept.join("\n")
505
+ );
506
+ }
507
+ return assemble(sections, truncated || sectionTruncated);
508
+ }
509
+ __name(buildDigest, "buildDigest");
510
+
511
+ // src/suggest.ts
512
+ function unfence(raw) {
513
+ const open = /```[ \t]*[A-Za-z0-9_-]*[ \t]*\r?\n?/.exec(raw);
514
+ if (open === null) return raw;
515
+ const lead = raw.slice(0, open.index);
516
+ if (lead.includes("[") || lead.includes("{")) return raw;
517
+ const body = raw.slice(open.index + open[0].length);
518
+ const close = body.lastIndexOf("```");
519
+ return close === -1 ? raw : body.slice(0, close);
520
+ }
521
+ __name(unfence, "unfence");
522
+ function parseSuggestions(raw) {
523
+ let parsed;
524
+ try {
525
+ parsed = JSON.parse(unfence(raw));
526
+ } catch (cause) {
527
+ return { ok: false, error: `the scan wrote invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}` };
528
+ }
529
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.suggestions) ? parsed.suggestions : void 0;
530
+ if (list === void 0) {
531
+ return { ok: false, error: "the scan did not write a list of suggestions" };
532
+ }
533
+ const suggestions = [];
534
+ const seen = /* @__PURE__ */ new Set();
535
+ for (const entry of list) {
536
+ if (entry === null || typeof entry !== "object") continue;
537
+ const row = entry;
538
+ const title = typeof row.title === "string" ? row.title.trim() : "";
539
+ if (title.length === 0) continue;
540
+ const stored = title.slice(0, MAX_TEXT);
541
+ const key = stored.toLowerCase();
542
+ if (seen.has(key)) continue;
543
+ seen.add(key);
544
+ const evidence = typeof row.evidence === "string" ? row.evidence.trim().slice(0, MAX_LABEL) : "";
545
+ suggestions.push({
546
+ title: stored,
547
+ rationale: typeof row.rationale === "string" ? row.rationale.trim().slice(0, MAX_DESC) : "",
548
+ priority: toPriority(row.priority),
549
+ // Absent optional fields are ABSENT KEYS, never '', matching TodoItem.
550
+ ...evidence.length > 0 ? { evidence } : {}
551
+ });
552
+ if (suggestions.length >= MAX_SUGGESTIONS) break;
553
+ }
554
+ return { ok: true, suggestions };
555
+ }
556
+ __name(parseSuggestions, "parseSuggestions");
557
+
219
558
  // src/index.ts
220
559
  var todoItemSchema = z.object({
221
560
  id: z.string().min(1),
@@ -240,7 +579,7 @@ var todoDomainSpec = {
240
579
  name: "dsh_todo",
241
580
  version: 2
242
581
  };
243
- var _replace_dec, _list_dec, _a, _init, _b;
582
+ var _readSuggestions_dec, _scanDigest_dec, _replace_dec, _list_dec, _a, _init, _b;
244
583
  var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
245
584
  /**
246
585
  * @param ctx - host context carrying the workspace registry.
@@ -267,19 +606,19 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
267
606
  * Runs once per harness home; a workspace whose db already has content is
268
607
  * left untouched, so a half-finished migration never clobbers newer data.
269
608
  */
270
- async [(_a = Service.init, _list_dec = [Remote], _replace_dec = [Remote], _a)]() {
609
+ async [(_a = Service.init, _list_dec = [Remote], _replace_dec = [Remote], _scanDigest_dec = [Remote], _readSuggestions_dec = [Remote], _a)]() {
271
610
  this.ctx.effect(() => () => this.close(), "dsh-todo: close workspace databases");
272
611
  const home = process.env.DSH_HOME;
273
612
  if (!home) return;
274
- const legacyPath = join2(home, "storages", "dsh_todo.json");
275
- const registryPath = join2(home, "storages", "workspace.json");
613
+ const legacyPath = join3(home, "storages", "dsh_todo.json");
614
+ const registryPath = join3(home, "storages", "workspace.json");
276
615
  try {
277
616
  if (!existsSync(legacyPath)) return;
278
- const legacy = JSON.parse(readFileSync(legacyPath, "utf8"));
617
+ const legacy = JSON.parse(readFileSync2(legacyPath, "utf8"));
279
618
  const records = legacy?.tables?.workspaces ?? {};
280
619
  let mapping = {};
281
620
  if (existsSync(registryPath)) {
282
- mapping = JSON.parse(readFileSync(registryPath, "utf8"))?.tables?.workspaces ?? {};
621
+ mapping = JSON.parse(readFileSync2(registryPath, "utf8"))?.tables?.workspaces ?? {};
283
622
  }
284
623
  let importedItems = 0;
285
624
  let importedWorkspaces = 0;
@@ -367,6 +706,69 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
367
706
  return { ok: true, list: this.readList(db) };
368
707
  });
369
708
  }
709
+ async scanDigest(request) {
710
+ return buildDigest(this.workspaceDir(request?.workspaceId));
711
+ }
712
+ async readSuggestions(request) {
713
+ const dir = this.workspaceDir(request?.workspaceId);
714
+ const runId = request?.runId;
715
+ if (typeof runId !== "string" || !/^[a-z0-9]+$/.test(runId)) {
716
+ throw new Error("dsh-todo: runId must be a non-empty lowercase alphanumeric string");
717
+ }
718
+ const path = join3(dir, ...suggestionsFileFor(runId).split("/"));
719
+ this.sweepOrphanResults(dir, runId);
720
+ let raw;
721
+ try {
722
+ raw = readFileSync2(path, "utf8");
723
+ } catch (err) {
724
+ const code = err.code;
725
+ if (code === "ENOENT") return { status: "pending" };
726
+ return { status: "error", error: `dsh-todo: the scan result could not be read: ${code ?? String(err)}` };
727
+ }
728
+ const parsed = parseSuggestions(raw);
729
+ try {
730
+ unlinkSync(path);
731
+ } catch {
732
+ }
733
+ if (!parsed.ok) return { status: "error", error: parsed.error };
734
+ return { status: "ready", suggestions: parsed.suggestions };
735
+ }
736
+ /**
737
+ * Delete every result file that is not the run currently reading.
738
+ *
739
+ * Per-run paths fix the cross-run bleed but introduce their own litter: a
740
+ * scan whose modal was closed still finishes and writes, and nobody ever
741
+ * reads that file. One orphan per abandoned scan accumulates in `.dsh`
742
+ * indefinitely, and abandoning a scan is the ordinary case, not the rare one.
743
+ *
744
+ * Sweeping on every poll — rather than at scan start — is deliberate and
745
+ * cheaper than it looks: a scan already polls this endpoint every 1.5s, one
746
+ * `readdir` of `.dsh` is trivially small beside the digest walk that preceded
747
+ * it, and it means a run started from a build with no sweep at all is still
748
+ * cleaned up by the next one. It also collects the legacy fixed-path file, so
749
+ * an upgrade needs no migration step.
750
+ *
751
+ * The regex is anchored on both ends: `.dsh` holds `todo.db` and whatever
752
+ * else the harness keeps there, and a sweep that guessed wider would delete a
753
+ * neighbour's data. Failure is swallowed throughout — this is housekeeping,
754
+ * and a scan that produced an answer must not fail over tidying.
755
+ *
756
+ * @param dir - the resolved workspace directory.
757
+ * @param runId - the run whose file must SURVIVE.
758
+ */
759
+ sweepOrphanResults(dir, runId) {
760
+ const keep = suggestionsFileFor(runId).split("/").pop();
761
+ try {
762
+ for (const name of readdirSync2(join3(dir, SUGGESTIONS_DIR))) {
763
+ if (name === keep || !SUGGESTIONS_FILE_RE.test(name)) continue;
764
+ try {
765
+ unlinkSync(join3(dir, SUGGESTIONS_DIR, name));
766
+ } catch {
767
+ }
768
+ }
769
+ } catch {
770
+ }
771
+ }
370
772
  /** Queue one whole read/compare/write behind this workspace's prior write. */
371
773
  async enqueue(workspaceId, run) {
372
774
  const prior = this.tails.get(workspaceId) ?? Promise.resolve();
@@ -418,6 +820,8 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
418
820
  _init = __decoratorStart(_b);
419
821
  __decorateElement(_init, 1, "list", _list_dec, _TodoService);
420
822
  __decorateElement(_init, 1, "replace", _replace_dec, _TodoService);
823
+ __decorateElement(_init, 1, "scanDigest", _scanDigest_dec, _TodoService);
824
+ __decorateElement(_init, 1, "readSuggestions", _readSuggestions_dec, _TodoService);
421
825
  __decoratorMetadata(_init, _TodoService);
422
826
  __name(_TodoService, "TodoService");
423
827
  // Per-fiber service grants: the workspace registry property is only readable