superdev-cli 0.1.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.
Files changed (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,173 @@
1
+ // Plugin manifests and the hook table. These are the files a host reads before
2
+ // any Superdev code runs, so a broken one fails at install time on someone
3
+ // else's machine rather than here.
4
+ //
5
+ // Absolute paths are the recurring defect: a manifest written on the author's
6
+ // machine points nowhere once installed, and it leaks a home directory.
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { ERROR, WARNING, finding, isKebab, readJson, rel } from "./common.mjs";
11
+
12
+ export const name = "manifests";
13
+
14
+ const ABSOLUTE_HOME = /(?:\/(?:Users|home)\/[A-Za-z0-9._-]+|[A-Z]:\\Users\\[A-Za-z0-9._-]+)/;
15
+ // A command may reference the plugin root, never a rooted filesystem path.
16
+ const ROOTED_COMMAND = /(?:^|\s)"?\/(?!\/)[A-Za-z0-9._-]+\//;
17
+
18
+ const CLAUDE_REQUIRED = ["name", "version", "description"];
19
+ const CODEX_INTERFACE_REQUIRED = [
20
+ "displayName", "shortDescription", "longDescription", "developerName", "category",
21
+ ];
22
+
23
+ function portability(path, text, findings) {
24
+ if (ABSOLUTE_HOME.test(text)) {
25
+ findings.push(finding("MF-010", ERROR, path,
26
+ "contains an absolute home path, which points nowhere once the plugin is installed elsewhere"));
27
+ }
28
+ if (text.includes("../")) {
29
+ findings.push(finding("MF-011", ERROR, path,
30
+ "contains a path escape (../), which resolves outside the plugin root"));
31
+ }
32
+ }
33
+
34
+ export async function run(root) {
35
+ const findings = [];
36
+
37
+ const claudeManifest = join(root, ".claude-plugin", "plugin.json");
38
+ if (!existsSync(claudeManifest)) {
39
+ findings.push(finding("MF-001", ERROR, ".claude-plugin/plugin.json",
40
+ "the Claude plugin manifest is missing; there is nothing to validate"));
41
+ } else {
42
+ const path = rel(root, claudeManifest);
43
+ const { value, error } = readJson(claudeManifest);
44
+ if (error) {
45
+ findings.push(finding("MF-002", ERROR, path, `manifest is not valid JSON: ${error}`));
46
+ } else {
47
+ for (const field of CLAUDE_REQUIRED) {
48
+ if (typeof value[field] !== "string" || !value[field].trim()) {
49
+ findings.push(finding("MF-003", ERROR, path, `required field "${field}" is missing or empty`));
50
+ }
51
+ }
52
+ if (!isKebab(value.name)) {
53
+ findings.push(finding("MF-004", ERROR, path,
54
+ `name "${value.name ?? ""}" is not kebab-case; a host derives directory and command names from it`));
55
+ }
56
+ portability(path, JSON.stringify(value), findings);
57
+ }
58
+ }
59
+
60
+ const marketplace = join(root, ".claude-plugin", "marketplace.json");
61
+ if (existsSync(marketplace)) {
62
+ const path = rel(root, marketplace);
63
+ const { value, error } = readJson(marketplace);
64
+ if (error) findings.push(finding("MF-002", ERROR, path, `marketplace manifest is not valid JSON: ${error}`));
65
+ else portability(path, JSON.stringify(value), findings);
66
+ }
67
+
68
+ const codexManifest = join(root, ".codex-plugin", "plugin.json");
69
+ if (!existsSync(codexManifest)) {
70
+ findings.push(finding("MF-005", ERROR, ".codex-plugin/plugin.json",
71
+ "the Codex plugin manifest is missing; Codex distribution is a supported surface"));
72
+ } else {
73
+ const path = rel(root, codexManifest);
74
+ const { value, error } = readJson(codexManifest);
75
+ if (error) {
76
+ findings.push(finding("MF-002", ERROR, path, `manifest is not valid JSON: ${error}`));
77
+ } else {
78
+ if (!value.author || typeof value.author !== "object" || typeof value.author.name !== "string" || !value.author.name.trim()) {
79
+ findings.push(finding("MF-006", ERROR, path, "author must be an object carrying a non-empty name"));
80
+ }
81
+ const iface = value.interface;
82
+ if (!iface || typeof iface !== "object") {
83
+ findings.push(finding("MF-007", ERROR, path, "interface object is missing"));
84
+ } else {
85
+ for (const field of CODEX_INTERFACE_REQUIRED) {
86
+ if (typeof iface[field] !== "string" || !iface[field].trim()) {
87
+ findings.push(finding("MF-007", ERROR, path, `interface.${field} must be a non-empty string`));
88
+ }
89
+ }
90
+ if (!("defaultPrompt" in iface) && !("default_prompt" in iface)) {
91
+ findings.push(finding("MF-007", ERROR, path, "interface.defaultPrompt is missing"));
92
+ }
93
+ if (!Array.isArray(iface.capabilities) || !iface.capabilities.every((c) => typeof c === "string" && c.trim())) {
94
+ findings.push(finding("MF-007", ERROR, path, "interface.capabilities must be an array of non-empty strings"));
95
+ }
96
+ }
97
+ portability(path, JSON.stringify(value), findings);
98
+ }
99
+ }
100
+
101
+ const hooksFile = join(root, "hooks", "hooks.json");
102
+ if (!existsSync(hooksFile)) {
103
+ findings.push(finding("MF-008", ERROR, "hooks/hooks.json",
104
+ "hooks.json is missing; the agent lifecycle hooks have no declaration"));
105
+ } else {
106
+ const path = rel(root, hooksFile);
107
+ const { value, error } = readJson(hooksFile);
108
+ if (error) {
109
+ findings.push(finding("MF-002", ERROR, path, `hooks.json is not valid JSON: ${error}`));
110
+ } else if (!value.hooks || typeof value.hooks !== "object") {
111
+ findings.push(finding("MF-009", ERROR, path, "hooks.json must carry a hooks object"));
112
+ } else {
113
+ for (const [event, groups] of Object.entries(value.hooks)) {
114
+ if (!Array.isArray(groups)) {
115
+ findings.push(finding("MF-009", ERROR, path, `hooks.${event} must be an array`));
116
+ continue;
117
+ }
118
+ for (const group of groups) {
119
+ for (const hook of group?.hooks ?? []) {
120
+ if (hook.type !== "command" || typeof hook.command !== "string" || !hook.command.trim()) {
121
+ findings.push(finding("MF-009", ERROR, path, `hooks.${event} carries a hook without a command`));
122
+ continue;
123
+ }
124
+ if (ROOTED_COMMAND.test(hook.command)) {
125
+ findings.push(finding("MF-010", ERROR, path,
126
+ `hooks.${event} runs an absolute path; use \${CLAUDE_PLUGIN_ROOT}`));
127
+ }
128
+ if (!hook.command.includes("${CLAUDE_PLUGIN_ROOT}")) {
129
+ findings.push(finding("MF-012", WARNING, path,
130
+ `hooks.${event} does not anchor its command to \${CLAUDE_PLUGIN_ROOT}, so it depends on the working directory`));
131
+ }
132
+ }
133
+ }
134
+ }
135
+ portability(path, JSON.stringify(value).replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, ""), findings);
136
+ }
137
+ }
138
+
139
+ // Every manifest that carries a version must carry the SAME version. Three
140
+ // manifests sat at 0.1.1 while package.json said 0.2.0, and nothing noticed,
141
+ // because this validator only checked that a version field was present. A
142
+ // version that disagrees with itself across distribution surfaces means an
143
+ // install instruction is wrong for at least one of them.
144
+ const pkg = readJson(join(root, "package.json")).value;
145
+ if (pkg?.version) {
146
+ const declared = [
147
+ [".claude-plugin/plugin.json", readJson(join(root, ".claude-plugin", "plugin.json")).value?.version],
148
+ [".codex-plugin/plugin.json", readJson(join(root, ".codex-plugin", "plugin.json")).value?.version],
149
+ [".claude-plugin/marketplace.json metadata",
150
+ readJson(join(root, ".claude-plugin", "marketplace.json")).value?.metadata?.version],
151
+ ...(readJson(join(root, ".claude-plugin", "marketplace.json")).value?.plugins ?? [])
152
+ .map((plugin, i) => [`.claude-plugin/marketplace.json plugins[${i}]`, plugin?.version]),
153
+ // The CLI the plugin says it needs. The two ship separately now, so this
154
+ // is the one number that could sensibly lag, and it is also the one that
155
+ // would silently warn every user that their tools are mismatched if it
156
+ // were left behind a release. One version for both halves means this
157
+ // field moves with the rest.
158
+ [".claude-plugin/plugin.json requires.cli",
159
+ readJson(join(root, ".claude-plugin", "plugin.json")).value?.requires?.cli],
160
+ [".codex-plugin/plugin.json requires.cli",
161
+ readJson(join(root, ".codex-plugin", "plugin.json")).value?.requires?.cli],
162
+ ];
163
+ for (const [where, version] of declared) {
164
+ if (version === undefined || version === null) continue;
165
+ if (version !== pkg.version) {
166
+ findings.push(finding("MF-013", ERROR, where,
167
+ `declares version "${version}" while package.json declares "${pkg.version}"`));
168
+ }
169
+ }
170
+ }
171
+
172
+ return { name, findings };
173
+ }
@@ -0,0 +1,123 @@
1
+ // Generated Markdown under talks/.
2
+ //
3
+ // Markdown here is a projection of accepted database content, never a second
4
+ // writable source of truth. Three things make that claim checkable: the marker
5
+ // says which record and revision produced the file, the hash says the body is
6
+ // still the body that was generated, and a relative link that resolves says the
7
+ // projection is internally consistent rather than merely well formed.
8
+ //
9
+ // A file whose hash no longer matches is not a defect in this validator: it is
10
+ // a pending edit proposal, and it is reported so that it gets reviewed rather
11
+ // than silently overwritten.
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, extname, join, resolve } from "node:path";
16
+ import { ERROR, finding, isDirectory, readText, rel, walk } from "./common.mjs";
17
+
18
+ export const name = "markdown";
19
+
20
+ // <!-- superdev:generated source=FEAT-0007 revision=42 hash=<sha256-of-body> -->
21
+ const MARKER = /^<!--\s*superdev:generated\s+source=(\S+)\s+revision=(\d+)\s+hash=([0-9a-f]{64})\s*-->\s*$/;
22
+
23
+ const INLINE_LINK = /!?\[[^\]]*\]\(\s*<?([^)<>\s]+)(?:\s+"[^"]*")?\s*>?\s*\)/g;
24
+ const REFERENCE_LINK = /^\s{0,3}\[[^\]]+\]:\s*<?([^\s<>]+)>?/gm;
25
+
26
+ const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
27
+
28
+ /**
29
+ * The body the hash covers: everything after the marker line, LF line endings,
30
+ * no trailing whitespace on any line, exactly one terminal newline. Stated in
31
+ * the module contract, restated here because a renderer and a validator that
32
+ * disagree about normalization produce a permanent false proposal.
33
+ */
34
+ export function normalizeBody(body) {
35
+ const normalized = String(body)
36
+ .replace(/\r\n/g, "\n")
37
+ .split("\n")
38
+ .map((line) => line.replace(/[ \t]+$/, ""))
39
+ .join("\n");
40
+ return normalized.replace(/\n+$/, "") + "\n";
41
+ }
42
+
43
+ export const bodyHash = (body) => createHash("sha256").update(normalizeBody(body)).digest("hex");
44
+
45
+ /** Every relative link target in a document, deduplicated, anchors removed. */
46
+ export function linkTargets(text) {
47
+ const targets = new Set();
48
+ for (const pattern of [INLINE_LINK, REFERENCE_LINK]) {
49
+ pattern.lastIndex = 0;
50
+ for (const match of text.matchAll(pattern)) {
51
+ const raw = match[1];
52
+ if (!raw || EXTERNAL.test(raw)) continue;
53
+ const target = raw.split("#")[0].split("?")[0];
54
+ if (target) targets.add(target);
55
+ }
56
+ }
57
+ return [...targets].sort();
58
+ }
59
+
60
+ export async function run(root) {
61
+ const findings = [];
62
+ const talks = join(root, "talks");
63
+
64
+ // A repository that has never been initialized as a Superdev project has no
65
+ // generated Markdown, and that is not a defect: there is no database, so
66
+ // nothing has been generated and nothing is missing. This is distinct from the
67
+ // absent-input case ADR-0019 requires a validator to fail on, which is when a
68
+ // project DOES exist and its documents are gone. Superdev's own repository is
69
+ // exactly this case: its documentation is docs/ and README, and it is not
70
+ // itself managed as a Superdev project.
71
+ const initialized = existsSync(join(root, ".superdev", "superdev.db"));
72
+
73
+ if (!isDirectory(talks)) {
74
+ if (!initialized) return { name, findings, note: "no project here, so no generated Markdown to inspect" };
75
+ findings.push(finding("MD-000", ERROR, "talks",
76
+ "this project has a database but no talks directory; run superdev docs generate"));
77
+ return { name, findings };
78
+ }
79
+
80
+ const files = walk(talks).filter((file) => extname(file) === ".md");
81
+ if (!files.length) {
82
+ if (!initialized) return { name, findings, note: "no project here, so no generated Markdown to inspect" };
83
+ findings.push(finding("MD-000", ERROR, "talks",
84
+ "this project has a database but talks/ holds no documents; run superdev docs generate"));
85
+ return { name, findings };
86
+ }
87
+
88
+ for (const file of files) {
89
+ const path = rel(root, file);
90
+ const text = readText(file);
91
+ if (text === null) {
92
+ findings.push(finding("MD-005", ERROR, path, "document is unreadable"));
93
+ continue;
94
+ }
95
+
96
+ const newline = text.indexOf("\n");
97
+ const firstLine = newline === -1 ? text : text.slice(0, newline);
98
+ const marker = firstLine.match(MARKER);
99
+ if (!marker) {
100
+ findings.push(finding("MD-001", ERROR, path,
101
+ "carries no superdev:generated marker, so nothing records which record and revision produced it"));
102
+ } else {
103
+ const [, source, , recordedHash] = marker;
104
+ if (!/^[A-Z]{1,5}-\d{4,}$/.test(source)) {
105
+ findings.push(finding("MD-002", ERROR, path, `marker source "${source}" is not a Superdev identifier`));
106
+ }
107
+ const actual = bodyHash(newline === -1 ? "" : text.slice(newline + 1));
108
+ if (actual !== recordedHash) {
109
+ findings.push(finding("MD-003", ERROR, path,
110
+ "body does not match the hash in its marker; this is a pending edit proposal, not a file to overwrite"));
111
+ }
112
+ }
113
+
114
+ for (const target of linkTargets(text)) {
115
+ const resolved = resolve(dirname(file), target);
116
+ if (!existsSync(resolved)) {
117
+ findings.push(finding("MD-004", ERROR, path, `relative link "${target}" does not resolve`));
118
+ }
119
+ }
120
+ }
121
+
122
+ return { name, findings };
123
+ }
@@ -0,0 +1,187 @@
1
+ // Migration validator.
2
+ //
3
+ // This one builds its artifact rather than reading one: it applies the whole
4
+ // ordered migration set forward into a throwaway database in a temp directory,
5
+ // proves the schema is intact and the guard triggers actually fire, and deletes
6
+ // it. Nothing is committed, nothing is simulated, and a passing run means the
7
+ // SQL in the repository really does produce the schema it claims to.
8
+ //
9
+ // It fails when there are no migrations. A migration validator that skips
10
+ // itself when the input is missing is how a broken schema ships.
11
+
12
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { availableMigrations, migrate } from "../../src/db/migrate.mjs";
16
+ import { create, mutate, paths, query, setStatus } from "../../src/db/store.mjs";
17
+ import { ERROR, finding } from "./common.mjs";
18
+
19
+ export const name = "migrations";
20
+
21
+ const MIGRATIONS_PATH = "src/db/migrations";
22
+
23
+ /** Run a probe that must be refused, and say what the refusal proved. */
24
+ async function mustRefuse(tmpRoot, code, path, expectedCode, description, action) {
25
+ try {
26
+ await mutate(tmpRoot, action);
27
+ } catch (err) {
28
+ const message = String(err?.message ?? err);
29
+ if (message.includes(expectedCode)) return null;
30
+ return finding(code, ERROR, path,
31
+ `${description} was refused with "${message}" rather than ${expectedCode}`);
32
+ }
33
+ return finding(code, ERROR, path, `${description} was allowed; ${expectedCode} did not fire`);
34
+ }
35
+
36
+ export async function run(root) {
37
+ const findings = [];
38
+ const dir = join(root, "src", "db", "migrations");
39
+ const available = availableMigrations(dir);
40
+
41
+ if (!available.length) {
42
+ findings.push(finding("MG-000", ERROR, MIGRATIONS_PATH,
43
+ "no migration files found; the schema cannot be applied or proven"));
44
+ return { name, findings };
45
+ }
46
+
47
+ const tmpRoot = mkdtempSync(join(tmpdir(), "superdev-validate-migrations-"));
48
+ try {
49
+ const dbFile = paths(tmpRoot).db;
50
+
51
+ // ------------------------------------------------- apply the full set forward
52
+ let applied;
53
+ try {
54
+ applied = await migrate(dbFile, { apply: true, dir });
55
+ } catch (err) {
56
+ findings.push(finding("MG-001", ERROR, MIGRATIONS_PATH,
57
+ `applying the migration set forward failed: ${String(err?.message ?? err)}`));
58
+ return { name, findings };
59
+ }
60
+
61
+ if (applied.applied.length !== available.length) {
62
+ findings.push(finding("MG-002", ERROR, MIGRATIONS_PATH,
63
+ `${available.length} migration file(s) on disk but ${applied.applied.length} applied`));
64
+ }
65
+
66
+ // ------------------------------------------------------ integrity and keys
67
+ const health = await query(tmpRoot, async (db) => {
68
+ const out = {};
69
+ try {
70
+ out.integrity = await db.all("PRAGMA integrity_check");
71
+ } catch (err) {
72
+ out.integrityError = String(err?.message ?? err);
73
+ }
74
+ try {
75
+ out.foreignKeys = await db.all("PRAGMA foreign_key_check");
76
+ } catch (err) {
77
+ out.foreignKeyError = String(err?.message ?? err);
78
+ }
79
+ out.bookkeeping = await db.all("SELECT version, name, checksum FROM applied_migrations ORDER BY version");
80
+ return out;
81
+ });
82
+
83
+ if (health.integrityError) {
84
+ findings.push(finding("MG-003", ERROR, MIGRATIONS_PATH, `integrity check could not run: ${health.integrityError}`));
85
+ } else {
86
+ const verdicts = (health.integrity ?? []).map((row) => String(Object.values(row)[0] ?? "")).filter(Boolean);
87
+ const bad = verdicts.filter((v) => v.toLowerCase() !== "ok");
88
+ if (!verdicts.length) findings.push(finding("MG-003", ERROR, MIGRATIONS_PATH, "integrity check returned nothing"));
89
+ else if (bad.length) findings.push(finding("MG-003", ERROR, MIGRATIONS_PATH, `integrity check reported: ${bad.join("; ")}`));
90
+ }
91
+
92
+ if (health.foreignKeyError) {
93
+ findings.push(finding("MG-004", ERROR, MIGRATIONS_PATH, `foreign key check could not run: ${health.foreignKeyError}`));
94
+ } else if ((health.foreignKeys ?? []).length) {
95
+ findings.push(finding("MG-004", ERROR, MIGRATIONS_PATH,
96
+ `foreign key check reported ${health.foreignKeys.length} violation(s) in a freshly migrated database`));
97
+ }
98
+
99
+ // -------------------------------- applied list equals the checked-in files
100
+ const onDisk = available.map((m) => `${m.version} ${m.name} ${m.checksum}`);
101
+ const recorded = (health.bookkeeping ?? []).map((row) => `${row.version} ${row.name} ${row.checksum}`);
102
+ if (onDisk.join("\n") !== recorded.join("\n")) {
103
+ const missing = onDisk.filter((entry) => !recorded.includes(entry)).map((e) => e.split(" ")[1]);
104
+ const extra = recorded.filter((entry) => !onDisk.includes(entry)).map((e) => e.split(" ")[1]);
105
+ findings.push(finding("MG-005", ERROR, MIGRATIONS_PATH,
106
+ `the applied migration list does not equal the migration files${missing.length ? `; not applied or altered: ${missing.join(", ")}` : ""}${extra.length ? `; applied but not on disk: ${extra.join(", ")}` : ""}`));
107
+ }
108
+
109
+ // ------------------------------------------------------- prove the triggers
110
+ let taskId = null;
111
+ try {
112
+ taskId = await mutate(tmpRoot, async (db) => {
113
+ const project = await create(db, "project", { name: "Validator probe", slug: "validator-probe" });
114
+ const module = await create(db, "module", {
115
+ project_id: project.id, name: "Probe module", slug: "probe-module",
116
+ }, { projectId: project.id });
117
+ const feature = await create(db, "feature", {
118
+ project_id: project.id, module_id: module.id, name: "Probe feature", slug: "probe-feature",
119
+ }, { projectId: project.id });
120
+ const task = await create(db, "task", {
121
+ project_id: project.id, feature_id: feature.id, name: "Probe task",
122
+ }, { projectId: project.id });
123
+ return task.id;
124
+ });
125
+ } catch (err) {
126
+ findings.push(finding("MG-006", ERROR, MIGRATIONS_PATH,
127
+ `the schema could not hold a minimal project, module, feature and task: ${String(err?.message ?? err)}`));
128
+ return { name, findings };
129
+ }
130
+
131
+ // The append-only probes need a real row: a statement that matches nothing
132
+ // never reaches the trigger and would pass without proving anything.
133
+ const eventId = await query(tmpRoot, (db) =>
134
+ db.value("SELECT id FROM activity_events ORDER BY sequence LIMIT 1"));
135
+ if (!eventId) {
136
+ findings.push(finding("MG-006", ERROR, MIGRATIONS_PATH,
137
+ "creating records recorded no activity event, so the append-only triggers cannot be proven"));
138
+ return { name, findings };
139
+ }
140
+
141
+ const refusals = [
142
+ await mustRefuse(tmpRoot, "MG-007", MIGRATIONS_PATH, "E_TASK_WITHOUT_CONTRACT",
143
+ "moving a task out of draft with no contract link",
144
+ (db) => setStatus(db, "task", taskId, "ready")),
145
+ await mustRefuse(tmpRoot, "MG-008", MIGRATIONS_PATH, "E_APPEND_ONLY",
146
+ "updating an activity event",
147
+ (db) => db.run("UPDATE activity_events SET summary = ? WHERE id = ?", "rewritten", eventId)),
148
+ await mustRefuse(tmpRoot, "MG-009", MIGRATIONS_PATH, "E_APPEND_ONLY",
149
+ "deleting an activity event",
150
+ (db) => db.run("DELETE FROM activity_events WHERE id = ?", eventId)),
151
+ ];
152
+ findings.push(...refusals.filter(Boolean));
153
+
154
+ // The constraint has to permit the mapped case too, or "always refuses" would
155
+ // read as "correctly refuses".
156
+ try {
157
+ await mutate(tmpRoot, async (db) => {
158
+ await db.run(
159
+ "INSERT INTO task_contract_links (task_id, target_type, target_id, relationship) VALUES (?,?,?,?)",
160
+ taskId, "acceptance_criterion", "AC-0001", "implements",
161
+ );
162
+ await setStatus(db, "task", taskId, "ready");
163
+ });
164
+ } catch (err) {
165
+ findings.push(finding("MG-010", ERROR, MIGRATIONS_PATH,
166
+ `a task with a contract link could not leave draft: ${String(err?.message ?? err)}`));
167
+ }
168
+
169
+ // Only meaningful once a transition exists; a missing one is already MG-010.
170
+ const historyId = await query(tmpRoot, (db) =>
171
+ db.value("SELECT id FROM status_history ORDER BY id LIMIT 1"));
172
+ if (historyId) {
173
+ const historyRefusal = await mustRefuse(tmpRoot, "MG-011", MIGRATIONS_PATH, "E_APPEND_ONLY",
174
+ "deleting a status transition",
175
+ (db) => db.run("DELETE FROM status_history WHERE id = ?", historyId));
176
+ if (historyRefusal) findings.push(historyRefusal);
177
+ }
178
+ } finally {
179
+ rmSync(tmpRoot, { recursive: true, force: true });
180
+ if (existsSync(tmpRoot)) {
181
+ findings.push(finding("MG-012", ERROR, MIGRATIONS_PATH,
182
+ "the throwaway database directory could not be removed"));
183
+ }
184
+ }
185
+
186
+ return { name, findings };
187
+ }
@@ -0,0 +1,136 @@
1
+ // What kind of testing this repository permits, and what it still refuses.
2
+ //
3
+ // The rule used to be absolute: no test file of any kind. Its reason was sound
4
+ // and is worth restating, because it is the thing this validator still protects.
5
+ // Every earlier attempt to keep "just a few" tests grew a fixture project, then
6
+ // phase gates over that fixture, then completion claims measured in test counts.
7
+ // A simulated product passing its own gates says nothing about the real one, and
8
+ // it is worse than saying nothing, because it feels like proof.
9
+ //
10
+ // What the absolute version also refused was cheap and valuable: an assertion
11
+ // that a pure function returns the right answer. The cost of that refusal was
12
+ // measured rather than guessed. Of thirteen defects found in one session, seven
13
+ // were in pure functions and would each have been caught by two or three lines:
14
+ // a paragraph joiner that split a wrapped sentence and produced a feature named
15
+ // after half a clause; a backup restore that resolved a bare filename against
16
+ // the wrong directory, so the name the product printed could not be handed back
17
+ // to it; a version comparison that told nobody on a pre-release that the release
18
+ // existed. None of them could fail a validator, because none of them is a
19
+ // structural claim. They are arithmetic, and arithmetic is what an assertion is
20
+ // for.
21
+ //
22
+ // So the line moved to where the original reasoning actually pointed:
23
+ //
24
+ // Allowed: a `.test.mjs` file beside the source it tests, run by node's own
25
+ // test runner, asserting on functions called directly.
26
+ //
27
+ // Still refused: a test directory, a fixture project, any third-party
28
+ // framework, and anything that stands a simulated product up to pass its own
29
+ // gates.
30
+ //
31
+ // The completion rule is untouched, and it is the part that matters most. A task
32
+ // completes on recorded evidence about the real product. A green suite is not
33
+ // evidence, a coverage figure is not progress, and neither may ever be offered
34
+ // as either.
35
+
36
+ import { join } from "node:path";
37
+ import { ERROR, WARNING, finding, isDirectory, readJson, rel, walk } from "./common.mjs";
38
+
39
+ export const name = "no-tests";
40
+
41
+ /** A tree of tests is the shape that grew a fixture project last time. */
42
+ const TEST_DIRECTORIES = new Set(["tests", "test", "__tests__", "spec", "fixtures"]);
43
+
44
+ /** Allowed: beside the source, named for it. Refused: every other shape. */
45
+ const COLOCATED_TEST = /^[a-z0-9-]+\.test\.mjs$/;
46
+ const OTHER_TEST_FILE = /\.(?:test|spec)\.(?:cjs|js|ts|tsx|jsx)$|\.spec\.mjs$/;
47
+
48
+ /**
49
+ * Frameworks and their runners. Node's own runner is deliberately absent: it
50
+ * needs no dependency, so it cannot grow one.
51
+ */
52
+ const TEST_RUNNER = /\b(?:jest|vitest|mocha|ava|tap|jasmine|karma|cypress|playwright test)\b/;
53
+
54
+ /** Where a colocated test may live: beside source, not anywhere in the tree. */
55
+ const TESTABLE_ROOTS = ["src/", "scripts/"];
56
+
57
+ export async function run(root) {
58
+ const findings = [];
59
+ if (!isDirectory(root)) {
60
+ findings.push(finding("NT-000", ERROR, ".", "the project root does not exist"));
61
+ return { name, findings };
62
+ }
63
+
64
+ // Read the tree once. walk() is a generator over the whole repository and
65
+ // calling it per file turned this validator quadratic.
66
+ const paths = new Set();
67
+ for (const file of walk(root)) paths.add(rel(root, file));
68
+
69
+ const trees = new Map();
70
+ let colocated = 0;
71
+
72
+ for (const path of paths) {
73
+ const segments = path.split("/");
74
+ const name_ = segments.at(-1);
75
+ const at = segments.findIndex((segment) => TEST_DIRECTORIES.has(segment));
76
+
77
+ if (at !== -1 && at < segments.length - 1) {
78
+ const tree = segments.slice(0, at + 1).join("/");
79
+ trees.set(tree, (trees.get(tree) ?? 0) + 1);
80
+ continue;
81
+ }
82
+
83
+ if (COLOCATED_TEST.test(name_)) {
84
+ if (!TESTABLE_ROOTS.some((prefix) => path.startsWith(prefix))) {
85
+ findings.push(finding("NT-005", ERROR, path,
86
+ `a unit test belongs beside the source it tests, under ${TESTABLE_ROOTS.join(" or ")}`));
87
+ continue;
88
+ }
89
+ // The source it names has to exist, or it tests nothing and will keep
90
+ // passing after that source is deleted.
91
+ const subject = path.replace(/\.test\.mjs$/, ".mjs");
92
+ if (!paths.has(subject)) {
93
+ findings.push(finding("NT-006", ERROR, path,
94
+ `there is no ${subject} for this to test, so it asserts on nothing that ships`));
95
+ continue;
96
+ }
97
+ colocated += 1;
98
+ continue;
99
+ }
100
+
101
+ if (OTHER_TEST_FILE.test(name_)) {
102
+ findings.push(finding("NT-001", ERROR, path,
103
+ "test file; a unit test is named <source>.test.mjs and sits beside its source"));
104
+ }
105
+ }
106
+
107
+ for (const [tree, count] of [...trees].sort()) {
108
+ findings.push(finding("NT-002", ERROR, tree,
109
+ `test or fixture tree holding ${count} file(s); a unit test sits beside the source it tests, and this repository keeps no fixture project`));
110
+ }
111
+
112
+ const { value: pkg } = readJson(join(root, "package.json"));
113
+
114
+ // `npm test` is allowed now, and only for node's own runner. A script that
115
+ // reaches for a framework is the first step back toward the tree this refuses.
116
+ for (const [script, command] of Object.entries(pkg?.scripts ?? {})) {
117
+ if (TEST_RUNNER.test(String(command))) {
118
+ findings.push(finding("NT-003", ERROR, "package.json",
119
+ `script "${script}" runs a third-party test runner; unit tests use node --test, which needs no dependency`));
120
+ }
121
+ }
122
+ for (const dependency of Object.keys({ ...pkg?.devDependencies, ...pkg?.dependencies })) {
123
+ if (TEST_RUNNER.test(dependency)) {
124
+ findings.push(finding("NT-004", ERROR, "package.json",
125
+ `dependency "${dependency}" is a test framework; unit tests use node --test`));
126
+ }
127
+ }
128
+
129
+ // Tests that nothing runs are the same as no tests, with more upkeep.
130
+ if (colocated > 0 && !pkg?.scripts?.test) {
131
+ findings.push(finding("NT-007", WARNING, "package.json",
132
+ `${colocated} unit test file(s) exist and no "test" script runs them`));
133
+ }
134
+
135
+ return { name, findings };
136
+ }