superdev-cli 0.1.0 → 0.1.2

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.
@@ -892,6 +892,52 @@ const splitSentences = (text) =>
892
892
  .map((s) => s.trim())
893
893
  .filter(Boolean);
894
894
 
895
+ /**
896
+ * A statement's name and its description, when the statement carries both.
897
+ *
898
+ * Briefs list their parts as `**Screens.** Four routes with shared layout,
899
+ * navigation, and session state.` Every word of that was taken as the name, so a
900
+ * product map read as a column of truncated sentences whose first several words
901
+ * were identical, and the module slug reached the filesystem as
902
+ * `screens-four-routes-with-shared-layout-navigation-and-sessio`, cut mid-word,
903
+ * as a committed directory path.
904
+ *
905
+ * A label is short, comes first, and has a description after it. The three forms
906
+ * briefs actually use are `Name. Rest`, `Name: Rest` and `Name - Rest`, and the
907
+ * dash arrives as a hyphen, an en dash or an em dash because a person typed it.
908
+ * When there is no description the whole statement is the name, minus the
909
+ * full stop that would otherwise be part of it.
910
+ *
911
+ * The limits are deliberately tight. A label is a noun phrase: "Weather client",
912
+ * "Course data". A long first clause is a sentence, and treating it as a label
913
+ * would rename every feature to its opening clause, which is a different defect
914
+ * with the same cause. Word count separates the two better than length does:
915
+ * "The outgoing chef records what is prepped and what ran out" is short enough to
916
+ * pass any character limit and is plainly not a name.
917
+ */
918
+ export function splitLabel(statement, limit = 60, words = 6) {
919
+ const text = String(statement ?? "").replace(/\s+/g, " ").trim();
920
+ if (!text) return { name: null, description: null };
921
+
922
+ // The dashes are written as escapes because the em dash character is refused in
923
+ // this project's own source, and this has to match one a person typed.
924
+ const separated = text.match(/^(.{2,}?)\s*(?:[.:]|\s[-\u2013\u2014])\s+(\S.*)$/);
925
+ if (separated) {
926
+ const label = separated[1].trim();
927
+ // A label holds no sentence-ending punctuation of its own, and a trailing
928
+ // "and" or "or" means the split landed inside a list rather than after a name.
929
+ if (
930
+ label.length <= limit &&
931
+ label.split(/\s+/).length <= words &&
932
+ !/[.!?:]/.test(label) &&
933
+ !/\b(and|or|but|with|for|to|of)$/i.test(label)
934
+ ) {
935
+ return { name: label, description: separated[2].trim() };
936
+ }
937
+ }
938
+ return { name: text.replace(/\s*\.$/, ""), description: null };
939
+ }
940
+
895
941
  const normalize = (text) => String(text ?? "").toLowerCase().replace(/\s+/g, " ").trim();
896
942
 
897
943
  function clean(text) {
@@ -14,7 +14,7 @@ import { ensureDatabase, mutate, paths, query, create, patch, setStatus, recordA
14
14
  import { slugify, uniqueSlug } from "../model/ids.mjs";
15
15
  import { sanitizeExternal } from "../model/screening.mjs";
16
16
  import { CAPABILITY_AREAS, SYSTEM_TASK_CATEGORIES, count } from "../model/vocabulary.mjs";
17
- import { buildConceptMap, detectProjectKind, ingestSources, inspectEvidence, titleOf } from "./discovery.mjs";
17
+ import { buildConceptMap, detectProjectKind, ingestSources, inspectEvidence, splitLabel, titleOf } from "./discovery.mjs";
18
18
  import {
19
19
  MATERIAL_AREAS,
20
20
  STACK_SLOTS,
@@ -104,7 +104,18 @@ export async function planInit(root, opts = {}) {
104
104
  warnings.push("The repository is larger than the inspection limit, so the evidence below is a sample rather than a census.");
105
105
  }
106
106
  if (detection.route === "adopt") {
107
- warnings.push(`This repository already has documentation (profile ${detection.docsProfile}). Initialization is refused here; adoptProject leaves those files exactly where they are.`);
107
+ // Name a command, never a function. This said "adoptProject leaves those
108
+ // files exactly where they are", which is the internal function, so the
109
+ // refusal told the reader the remedy in a vocabulary they cannot type. It
110
+ // also stayed silent about the confidence, and a low-confidence detection is
111
+ // exactly the case where the reader should know to override it.
112
+ warnings.push([
113
+ `This repository already has documentation (profile ${detection.docsProfile}, ${detection.confidence} confidence).`,
114
+ "Initialization is refused here. Run superdev adopt, which leaves every existing file exactly where it is.",
115
+ detection.confidence === "low" || detection.confidence === "unknown"
116
+ ? "If those documents are input rather than a record of this project, for example a brief you are initializing from, run superdev init --adopt to say so and initialization will proceed."
117
+ : null,
118
+ ].filter(Boolean).join(" "));
108
119
  }
109
120
  if (existing.project) {
110
121
  warnings.push(`Project ${existing.project.id} already exists. Initialization will report what is present and create only what is missing.`);
@@ -372,15 +383,6 @@ export async function applyInit(root, opts = {}) {
372
383
  // Step 14 and 15 run outside every transaction: both open their own.
373
384
  // `notRun` rather than `skipped`: both generate and deriveAll return their own
374
385
  // `skipped` collection, and an empty array is still truthy.
375
- let documents = { notRun: "documentation generation was turned off for this run" };
376
- if (opts.generateDocs !== false) {
377
- documents = await runQuietly(async () => {
378
- const { generate } = await import("../docs/render.mjs");
379
- return generate(at, { apply: true });
380
- }, "documentation could not be generated");
381
- }
382
- record(14, outcomeOf(documents), documents.error ?? documents.notRun ?? summarize(documents));
383
-
384
386
  let tasks = { notRun: "task derivation was turned off for this run" };
385
387
  if (opts.deriveTasks !== false) {
386
388
  tasks = await runQuietly(async () => {
@@ -408,6 +410,25 @@ export async function applyInit(root, opts = {}) {
408
410
  metadata: { route: plan.route, questions: questions.length, discoveryItems: conceptMap.created.length, reused: created.reused },
409
411
  }));
410
412
 
413
+ // Documents are generated last, after every record and every event init will
414
+ // write, and the step is still reported as 14 because the brief's order is
415
+ // what a reader follows.
416
+ //
417
+ // It ran before task derivation and before the closing event, so a document
418
+ // was stamped with a revision lower than the project's own, and freshness
419
+ // compares the two. Every project therefore began its life reporting that its
420
+ // documentation was built from an older database revision, seconds after init
421
+ // wrote it, and no command the user could run would clear it. A gate that is
422
+ // red on arrival teaches the reader that red means nothing.
423
+ let documents = { notRun: "documentation generation was turned off for this run" };
424
+ if (opts.generateDocs !== false) {
425
+ documents = await runQuietly(async () => {
426
+ const { generate } = await import("../docs/render.mjs");
427
+ return generate(at, { apply: true });
428
+ }, "documentation could not be generated");
429
+ }
430
+ record(14, outcomeOf(documents), documents.error ?? documents.notRun ?? summarize(documents));
431
+
411
432
  return {
412
433
  root: at,
413
434
  project: created.project,
@@ -485,6 +506,34 @@ const titleFrom = (name) =>
485
506
  * with no goal candidates gets no goals, and the gap stays visible as the
486
507
  * unknown the concept map already wrote down.
487
508
  */
509
+ /**
510
+ * The module a feature names, or nothing.
511
+ *
512
+ * Matching is on the module's name appearing in the feature's own words, which is
513
+ * how a brief actually signals ownership: "Read the previous shift's handover"
514
+ * belongs to "Handover", and says so. A single common word is not a signal, so
515
+ * short and structural words are ignored, and nothing matching means nothing is
516
+ * claimed.
517
+ */
518
+ const STRUCTURAL = new Set(["the", "and", "for", "with", "data", "core", "app", "api", "web", "ui", "service", "client", "server", "module", "system"]);
519
+
520
+ function ownerFor(modules, statement) {
521
+ const text = String(statement ?? "").toLowerCase();
522
+ let best = null;
523
+ for (const module of modules) {
524
+ const words = String(module.name).toLowerCase().split(/[^a-z0-9]+/)
525
+ .filter((word) => word.length > 3 && !STRUCTURAL.has(word));
526
+ if (!words.length) continue;
527
+ const hits = words.filter((word) => text.includes(word)).length;
528
+ // Every distinctive word of the module's name has to appear, so "Shot log"
529
+ // does not claim a feature that merely mentions a shot.
530
+ if (hits === words.length && (!best || words.length > best.words)) {
531
+ best = { module, words: words.length };
532
+ }
533
+ }
534
+ return best?.module ?? null;
535
+ }
536
+
488
537
  async function draftProduct(root, project, { actor, at, milestones }) {
489
538
  const outcome = await mutate(root, async (db) => {
490
539
  const items = await db.all(
@@ -513,9 +562,13 @@ async function draftProduct(root, project, { actor, at, milestones }) {
513
562
 
514
563
  const modules = [];
515
564
  for (const [index, item] of moduleCandidates.entries()) {
516
- const name = trim(item.statement.replace(/^Module candidate:\s*/, ""), 120);
565
+ const stated = item.statement.replace(/^Module candidate:\s*/, "");
566
+ // The name is the label the brief gave the part, and the sentence after it
567
+ // is what the part does. Both used to become the name.
568
+ const { name: label, description } = splitLabel(stated);
569
+ const name = trim(label, 120);
517
570
  if (!name) continue;
518
- const module = await createModule(db, project, name, item.statement, index, at);
571
+ const module = await createModule(db, project, name, description ?? stated, index, at);
519
572
  if (!module) continue;
520
573
  modules.push(module);
521
574
  await convert(db, item, "module", module.id, at);
@@ -528,14 +581,27 @@ async function draftProduct(root, project, { actor, at, milestones }) {
528
581
  }
529
582
 
530
583
  const features = [];
531
- for (const [index, item] of featureCandidates.entries()) {
532
- const name = trim(item.statement, 120);
584
+ const unowned = [];
585
+ for (const item of featureCandidates) {
586
+ const { name: label } = splitLabel(item.statement);
587
+ const name = trim(label, 120);
533
588
  if (!name || !modules.length) continue;
534
589
  if (await db.get("SELECT 1 FROM features WHERE project_id = ? AND name = ?", project.id, name)) continue;
535
590
  const slug = await uniqueSlug(db, "features", project.id, name, "feature");
591
+ // Which module owns this feature is read from the feature, never from where
592
+ // it happened to sit in the list.
593
+ //
594
+ // It was `modules[index % modules.length]`, so feature n went to module n.
595
+ // Four features that were all screens landed on four different modules, one
596
+ // per supporting library, and the result was defensible only because the
597
+ // two lists happened to be the same length and in a compatible order.
598
+ // Reorder either list in the brief and the map becomes nonsense that reads
599
+ // as deliberate.
600
+ const matched = ownerFor(modules, item.statement);
601
+ const owner = matched ?? modules[0];
536
602
  const feature = await create(db, "feature", {
537
603
  project_id: project.id,
538
- module_id: modules[index % modules.length].id,
604
+ module_id: owner.id,
539
605
  name,
540
606
  slug,
541
607
  purpose: item.statement,
@@ -547,9 +613,54 @@ async function draftProduct(root, project, { actor, at, milestones }) {
547
613
  updated_at: at,
548
614
  }, { projectId: project.id, activity: false });
549
615
  features.push(feature);
616
+ // An unmatched feature still needs a module, so it gets the first one and
617
+ // the guess is written down as a question rather than left to look decided.
618
+ if (!matched && modules.length > 1) unowned.push({ feature, owner });
550
619
  await convert(db, item, "feature", feature.id, at);
551
620
  }
552
621
 
622
+ // What the brief said is out of scope, stored where the product reads it.
623
+ //
624
+ // An "Out of scope" section became a discovery item of kind `exclusion` and
625
+ // stopped there. project_scope_items, the table the product document reads,
626
+ // was written by nothing at all, so the generated foundations said "None
627
+ // declared. An empty list here is a gap, not a statement" to somebody who had
628
+ // just declared three. The most valuable section of a brief was parsed,
629
+ // stored as a proposal, and contradicted in the same run.
630
+ const exclusions = items.filter((i) => i.kind === "exclusion");
631
+ const scope = [];
632
+ for (const [index, item] of exclusions.entries()) {
633
+ const { name: statement, description: why } = splitLabel(item.statement);
634
+ if (!statement) continue;
635
+ const already = await db.get(
636
+ "SELECT id FROM project_scope_items WHERE project_id = ? AND statement = ?", project.id, statement);
637
+ if (already) continue;
638
+ const row = await create(db, "project_scope_item", {
639
+ project_id: project.id,
640
+ direction: "non_goal",
641
+ statement,
642
+ why: why ?? item.provenance ?? null,
643
+ sequence: index,
644
+ }, { projectId: project.id, activity: false });
645
+ scope.push(row);
646
+ await convert(db, item, "project_scope_item", row.id, at);
647
+ }
648
+
649
+ // Every guess about ownership, asked rather than assumed.
650
+ for (const { feature, owner } of unowned) {
651
+ await create(db, "question", {
652
+ project_id: project.id,
653
+ scope_type: "feature",
654
+ scope_id: feature.id,
655
+ question: `Which module owns ${feature.name}?`,
656
+ why_it_matters: `The brief named ${modules.length} modules and none of them appears in this feature, so it was placed in ${owner.name} to have a home. Module ownership decides which test plan covers it and which documentation directory it is written to.`,
657
+ recommendation: `Leave it in ${owner.name} if that is right, or run superdev feature move ${feature.id} --module <MOD-id>.`,
658
+ alternatives_json: JSON.stringify(modules.filter((m) => m.id !== owner.id).map((m) => m.name)),
659
+ status: "open",
660
+ created_at: at,
661
+ }, { projectId: project.id, activity: false });
662
+ }
663
+
553
664
  const created = [];
554
665
  for (const [index, supplied] of (milestones ?? []).entries()) {
555
666
  created.push(await createMilestone(db, project, clean(supplied.name), clean(supplied.outcome), index, at));
@@ -564,24 +675,24 @@ async function draftProduct(root, project, { actor, at, milestones }) {
564
675
 
565
676
  for (const module of modules) await seedModuleCompleteness(db, module.id, { at });
566
677
 
567
- if (goals.length || modules.length || features.length || created.length) {
678
+ if (goals.length || modules.length || features.length || created.length || scope.length) {
568
679
  await recordActivity(db, project.id, {
569
680
  type: "specification_changed",
570
- summary: `Drafted ${count(goals.length, "goal")}, ${count(modules.length, "module")}, ${count(features.length, "feature")} and ${count(created.filter(Boolean).length, "milestone")} from the concept map`,
681
+ summary: `Drafted ${count(goals.length, "goal")}, ${count(modules.length, "module")}, ${count(features.length, "feature")}, ${count(created.filter(Boolean).length, "milestone")} and ${count(scope.length, "non-goal")} from the concept map`,
571
682
  actor,
572
- metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length },
683
+ metadata: { goals: goals.length, modules: modules.length, features: features.length, milestones: created.filter(Boolean).length, nonGoals: scope.length },
573
684
  });
574
685
  }
575
686
 
576
- return { goals, modules, features, milestones: created.filter(Boolean) };
687
+ return { goals, modules, features, milestones: created.filter(Boolean), nonGoals: scope };
577
688
  });
578
689
 
579
- const total = outcome.goals.length + outcome.modules.length + outcome.features.length;
690
+ const total = outcome.goals.length + outcome.modules.length + outcome.features.length + outcome.nonGoals.length;
580
691
  return {
581
692
  ...outcome,
582
693
  created: total > 0,
583
694
  detail: total
584
- ? `${count(outcome.goals.length, "goal")}, ${count(outcome.modules.length, "module")}, ${count(outcome.features.length, "feature")}, ${count(outcome.milestones.length, "milestone")}`
695
+ ? `${count(outcome.goals.length, "goal")}, ${count(outcome.modules.length, "module")}, ${count(outcome.features.length, "feature")}, ${count(outcome.milestones.length, "milestone")}, ${count(outcome.nonGoals.length, "non-goal")}`
585
696
  : "the concept map held no candidates to convert, so nothing was invented to fill the gap",
586
697
  };
587
698
  }