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,149 @@
1
+ // Shared plumbing for the deterministic validators: the finding shape, the
2
+ // directories that are never project-owned, and how to list the files this
3
+ // project actually authors.
4
+ //
5
+ // Kept deliberately thin. A validator that needs a rule of its own keeps that
6
+ // rule in its own module, so reading one validator tells the whole story.
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
10
+ import { join, relative, sep, extname } from "node:path";
11
+
12
+ export const ERROR = "error";
13
+ export const WARNING = "warning";
14
+
15
+ /** A finding is data, not a sentence: the runner formats, the validator states. */
16
+ export const finding = (code, severity, path, message) => ({ code, severity, path, message });
17
+
18
+ /** Repo-relative, forward slashes. Absolute machine paths never reach output. */
19
+ export const rel = (root, abs) => relative(root, abs).split(sep).join("/") || ".";
20
+
21
+ /**
22
+ * Directories whose contents this project does not author. Build output and
23
+ * caches are excluded because rewriting or judging them says nothing about the
24
+ * source they came from.
25
+ */
26
+ export const SKIP_DIRS = new Set([
27
+ ".git", "node_modules", ".superdev", ".impeccable", ".playwright-mcp",
28
+ "dist", "build", "coverage", ".next", ".turbo", ".cache", ".venv",
29
+ ]);
30
+
31
+ /** Extensions whose content this project authors or generates. */
32
+ export const TEXT_EXTENSIONS = new Set([
33
+ ".md", ".mjs", ".cjs", ".js", ".ts", ".tsx", ".jsx", ".json", ".jsonc",
34
+ ".html", ".css", ".txt", ".yml", ".yaml", ".sql", ".sh", ".toml",
35
+ ]);
36
+
37
+ /** Every file under `dir`, depth first, skipping directories nobody authors. */
38
+ export function walk(dir, { skip = SKIP_DIRS } = {}) {
39
+ const out = [];
40
+ if (!existsSync(dir)) return out;
41
+ const stack = [dir];
42
+ while (stack.length) {
43
+ const current = stack.pop();
44
+ let entries;
45
+ try {
46
+ entries = readdirSync(current, { withFileTypes: true });
47
+ } catch {
48
+ continue;
49
+ }
50
+ for (const entry of entries) {
51
+ if (skip.has(entry.name)) continue;
52
+ const full = join(current, entry.name);
53
+ // Symlinks are followed by neither walker: a link can leave the tree.
54
+ if (entry.isSymbolicLink()) continue;
55
+ if (entry.isDirectory()) stack.push(full);
56
+ else if (entry.isFile()) out.push(full);
57
+ }
58
+ }
59
+ return out.sort();
60
+ }
61
+
62
+ export function readText(file) {
63
+ try {
64
+ return readFileSync(file, "utf8");
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function readJson(file) {
71
+ const text = readText(file);
72
+ if (text === null) return { error: "unreadable" };
73
+ try {
74
+ return { value: JSON.parse(text) };
75
+ } catch (err) {
76
+ return { error: String(err.message ?? err) };
77
+ }
78
+ }
79
+
80
+ export const isDirectory = (path) => existsSync(path) && statSync(path).isDirectory();
81
+
82
+ /**
83
+ * Text files this project owns, repo-relative. Git decides ownership when the
84
+ * root is a repository: tracked files plus untracked ones git is not ignoring.
85
+ * Tracked alone would let a file authored this minute escape every content rule
86
+ * until someone committed it, which is exactly when the rule matters. Otherwise
87
+ * the tree is walked with the same exclusions.
88
+ */
89
+ export function ownedFiles(root) {
90
+ let listed = [];
91
+ try {
92
+ listed = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
93
+ cwd: root,
94
+ encoding: "utf8",
95
+ maxBuffer: 64 * 1024 * 1024,
96
+ stdio: ["ignore", "pipe", "ignore"],
97
+ })
98
+ .split("\0")
99
+ .filter(Boolean);
100
+ } catch {
101
+ listed = walk(root).map((abs) => rel(root, abs));
102
+ }
103
+ return listed
104
+ .filter((path) => TEXT_EXTENSIONS.has(extname(path)))
105
+ .filter((path) => !path.split("/").some((segment) => SKIP_DIRS.has(segment)))
106
+ .filter((path) => existsSync(join(root, path)))
107
+ .sort();
108
+ }
109
+
110
+ /** kebab-case, the only shape a plugin or skill name may take. */
111
+ export const isKebab = (value) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(value ?? ""));
112
+
113
+ /**
114
+ * Minimal frontmatter reader, scoped to the key/value frontmatter this project
115
+ * writes. Not a general YAML parser, and deliberately not one: a validator that
116
+ * needs a parser dependency stops being deterministic plumbing.
117
+ */
118
+ export function parseFrontmatter(text) {
119
+ const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
120
+ if (!match) return null;
121
+ const fields = {};
122
+ let currentKey = null;
123
+ for (const line of match[1].split(/\r?\n/)) {
124
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
125
+ if (kv) {
126
+ currentKey = kv[1];
127
+ fields[currentKey] = kv[2];
128
+ } else if (currentKey && /^\s+\S/.test(line)) {
129
+ fields[currentKey] += " " + line.trim();
130
+ }
131
+ }
132
+ return fields;
133
+ }
134
+
135
+ /** Import specifiers in an ESM module: static, re-export, and literal dynamic. */
136
+ export function importSpecifiers(text) {
137
+ const specs = [];
138
+ const patterns = [
139
+ /(?:^|\n)\s*import\s[^;]*?from\s+["']([^"']+)["']/g,
140
+ /(?:^|\n)\s*import\s+["']([^"']+)["']/g,
141
+ /(?:^|\n)\s*export\s[^;]*?from\s+["']([^"']+)["']/g,
142
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
143
+ /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
144
+ ];
145
+ for (const pattern of patterns) {
146
+ for (const match of String(text).matchAll(pattern)) specs.push(match[1]);
147
+ }
148
+ return specs;
149
+ }
@@ -0,0 +1,225 @@
1
+ // Does the recorded data model match the schema the migrations actually build?
2
+ //
3
+ // Every data entity claims a table and a set of fields with declared types and
4
+ // nullability. That claim is only worth something if it is checked against the
5
+ // database the ordered migrations produce, so this validator builds a database
6
+ // from the migrations alone and compares it against what the records promise.
7
+ //
8
+ // It is a validator, not a test: it reads a real artifact and reports what is
9
+ // there. Nothing here asserts on behaviour the project chose, and no fixture is
10
+ // invented to make a comparison pass.
11
+
12
+ import { execFileSync } from "node:child_process";
13
+ import { mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { ERROR, WARNING, finding } from "./common.mjs";
18
+
19
+ /** SQLite reports declared types verbatim, so compare on the leading keyword. */
20
+ const baseType = (declared) => String(declared || "").trim().toLowerCase().split(/[ (]/)[0];
21
+
22
+ /**
23
+ * Types the records use and the column types that satisfy them. The record
24
+ * vocabulary is deliberately smaller than SQLite's, so this maps rather than
25
+ * demands an exact string match.
26
+ */
27
+ const TYPE_MATCHES = {
28
+ text: ["text", "varchar", "char", "clob"],
29
+ integer: ["integer", "int", "bigint", "smallint"],
30
+ real: ["real", "float", "double", "numeric", "decimal"],
31
+ blob: ["blob"],
32
+ boolean: ["integer", "int", "boolean"],
33
+ json: ["text", "blob"],
34
+ timestamp: ["text", "integer", "datetime"],
35
+ date: ["text", "integer", "date"],
36
+ };
37
+
38
+ /**
39
+ * Build a database from the migrations alone, in a temporary directory.
40
+ *
41
+ * This is what makes the check meaningful: comparing the records against the
42
+ * working database would only prove the working database agrees with itself,
43
+ * and would say nothing about whether a fresh install lands in the same shape.
44
+ */
45
+ async function schemaFromMigrations(root) {
46
+ const dir = mkdtempSync(join(tmpdir(), "superdev-schema-"));
47
+ const file = join(dir, "schema-probe.db");
48
+ try {
49
+ const { migrate } = await import(join(root, "src/db/migrate.mjs"));
50
+ await migrate(file, { apply: true });
51
+ const { read } = await import(join(root, "src/db/connect.mjs"));
52
+ return await read(file, async (db) => {
53
+ const tables = new Map();
54
+ const names = await db.all(
55
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
56
+ );
57
+ for (const { name } of names) {
58
+ const columns = await db.all(`PRAGMA table_info(${JSON.stringify(name)})`);
59
+ tables.set(name, new Map(columns.map((c) => [c.name, c])));
60
+ }
61
+ return tables;
62
+ });
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Compare every recorded entity and field against that schema.
70
+ *
71
+ * Entities that name no real table are reported once as a warning rather than
72
+ * once per field, because the field findings would all say the same thing.
73
+ */
74
+ export async function validateDataModel(root) {
75
+ const findings = [];
76
+ const { query } = await import(join(root, "src/db/store.mjs"));
77
+
78
+ let entities;
79
+ let fields;
80
+ try {
81
+ ({ entities, fields } = await query(root, async (db) => ({
82
+ entities: await db.all("SELECT id, name, purpose FROM data_entities ORDER BY id"),
83
+ fields: await db.all(
84
+ "SELECT entity_id, name, type, nullable FROM data_fields ORDER BY entity_id, sequence",
85
+ ),
86
+ })));
87
+ } catch (error) {
88
+ // No project database is not a failure: this validator has nothing to say
89
+ // about a checkout that has not been initialised.
90
+ return [finding("data-model-unreadable", WARNING, ".superdev",
91
+ `The project database could not be read, so the data model was not checked: ${error.message}`)];
92
+ }
93
+
94
+ if (entities.length === 0) return findings;
95
+
96
+ const schema = await schemaFromMigrations(root);
97
+ const byEntity = new Map();
98
+ for (const f of fields) {
99
+ if (!byEntity.has(f.entity_id)) byEntity.set(f.entity_id, []);
100
+ byEntity.get(f.entity_id).push(f);
101
+ }
102
+
103
+ for (const entity of entities) {
104
+ const table = schema.get(entity.name);
105
+ if (!table) {
106
+ findings.push(finding("data-entity-has-no-table", WARNING, "src/db/migrations",
107
+ `${entity.id} records the entity "${entity.name}", which the migrations never create.`));
108
+ continue;
109
+ }
110
+ for (const field of byEntity.get(entity.id) ?? []) {
111
+ const column = table.get(field.name);
112
+ if (!column) {
113
+ findings.push(finding("data-field-has-no-column", ERROR, "src/db/migrations",
114
+ `${entity.id} declares ${entity.name}.${field.name}, which the table does not have.`));
115
+ continue;
116
+ }
117
+ const want = TYPE_MATCHES[baseType(field.type)];
118
+ if (want && !want.includes(baseType(column.type))) {
119
+ findings.push(finding("data-field-type-differs", WARNING, "src/db/migrations",
120
+ `${entity.id} declares ${entity.name}.${field.name} as ${field.type}, and the column is ${column.type}.`));
121
+ }
122
+ // notnull is 1 when the column refuses null, so it is the inverse of the
123
+ // recorded nullable flag.
124
+ const columnNullable = column.notnull === 0;
125
+ const recordedNullable = field.nullable === 1 || field.nullable === true;
126
+ if (columnNullable !== recordedNullable) {
127
+ findings.push(finding("data-field-nullability-differs", ERROR, "src/db/migrations",
128
+ `${entity.id} records ${entity.name}.${field.name} as ${recordedNullable ? "nullable" : "not null"}, and the column is ${columnNullable ? "nullable" : "not null"}.`));
129
+ }
130
+ }
131
+ }
132
+ return findings;
133
+ }
134
+
135
+ /**
136
+ * Prove the recovery path the spec actually promises.
137
+ *
138
+ * The spec mandates a backup before migration rather than down-migration files,
139
+ * so getting back means restoring that backup. This applies the migrations to a
140
+ * throwaway copy and restores it, reporting what was observed either way.
141
+ */
142
+ export async function validateMigrationRecovery(root) {
143
+ const dir = mkdtempSync(join(tmpdir(), "superdev-recovery-"));
144
+ try {
145
+ const file = join(dir, "recovery-probe.db");
146
+ const { migrate, currentVersion, backupBeforeMigration } = await import(join(root, "src/db/migrate.mjs"));
147
+ await migrate(file, { apply: true });
148
+ const after = await currentVersion(file);
149
+ if (!after) {
150
+ return [finding("migration-leaves-no-version", ERROR, "src/db/migrate.mjs",
151
+ "Applying every migration left the schema version unset, so no upgrade can tell what it is upgrading from.")];
152
+ }
153
+ const backup = await backupBeforeMigration(file, "recovery-check");
154
+ const restored = await currentVersion(backup);
155
+ if (restored !== after) {
156
+ return [finding("backup-differs-from-source", ERROR, "src/db/migrate.mjs",
157
+ `The pre-migration backup reports schema version ${restored} where the database it came from reports ${after}.`)];
158
+ }
159
+ return [];
160
+ } catch (error) {
161
+ return [finding("migration-recovery-failed", ERROR, "src/db/migrate.mjs",
162
+ `The migrate and restore path did not complete: ${error.message}`)];
163
+ } finally {
164
+ rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Can the interface name every state the schema allows?
170
+ *
171
+ * The control centre maps a stored status to a tone, a glyph and a sentence,
172
+ * and anything it has not met renders as a state it has no description for. It
173
+ * had no entry for `reached` or `retired`, both of which the schema allows and
174
+ * the database holds, so the blueprint announced thirteen records as states
175
+ * nobody had described.
176
+ *
177
+ * The vocabularies are read from the CHECK constraints in the migrations, which
178
+ * is where they are actually defined. Reading them from anywhere else would
179
+ * check the interface against a second list that can itself drift.
180
+ */
181
+ export async function validateStatusVocabulary(root) {
182
+ const { readFileSync, readdirSync, existsSync } = await import("node:fs");
183
+ const map = join(root, "ui/src/components/shell/status.tsx");
184
+ if (!existsSync(map)) return [];
185
+ const source = readFileSync(map, "utf8");
186
+ const known = new Set(
187
+ [...source.matchAll(/^\s{2}([a-z_]+):\s*"(?:complete|active|attention|blocked|idle|retired|unrecognized)",/gm)]
188
+ .map((m) => m[1]),
189
+ );
190
+
191
+ const dir = join(root, "src/db/migrations");
192
+ const allowed = new Map();
193
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
194
+ const sql = readFileSync(join(dir, file), "utf8");
195
+ // Only the columns the interface renders as a status. A CHECK on a kind or
196
+ // a category is a different vocabulary and is not shown as a state.
197
+ for (const m of sql.matchAll(
198
+ /(status|state|result|applicability|epistemic_status|sync_status|screening_status|last_check_result)\s+TEXT[\s\S]{0,240}?CHECK\s*\(\s*\1\s+IN\s*\(([^)]*)\)/gi,
199
+ )) {
200
+ for (const value of m[2].split(",")) {
201
+ const word = value.trim().replace(/^'|'$/g, "");
202
+ if (word) allowed.set(word, file);
203
+ }
204
+ }
205
+ }
206
+
207
+ return [...allowed]
208
+ .filter(([word]) => !known.has(word))
209
+ .map(([word, file]) => finding("status-not-described", WARNING,
210
+ "ui/src/components/shell/status.tsx",
211
+ `The schema allows the status ${word} (${file}) and the control centre has no description for it, so a record holding it renders as a state nobody described.`));
212
+ }
213
+
214
+ export const name = "data-model";
215
+
216
+ export async function run(root) {
217
+ return {
218
+ name,
219
+ findings: [
220
+ ...await validateDataModel(root),
221
+ ...await validateMigrationRecovery(root),
222
+ ...await validateStatusVocabulary(root),
223
+ ],
224
+ };
225
+ }
@@ -0,0 +1,69 @@
1
+ // Dependency policy. Every package declared here has to exist on a machine
2
+ // Superdev did not install anything on, so the list stays at exactly what the
3
+ // database engine needs. Versions are pinned rather than ranged, because a
4
+ // caret range means the plugin someone installed last month and the plugin they
5
+ // install today are not the same software.
6
+
7
+ import { join } from "node:path";
8
+ import { ERROR, finding, readJson } from "./common.mjs";
9
+ import { RUNTIME_DEPENDENCY } from "./imports.mjs";
10
+
11
+ export const name = "dependencies";
12
+
13
+ export const ALLOWED_RUNTIME = new Set([RUNTIME_DEPENDENCY]);
14
+
15
+ /** The Node floor the module contract states. */
16
+ const MINIMUM_NODE_MAJOR = 20;
17
+
18
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
19
+
20
+ export async function run(root) {
21
+ const findings = [];
22
+ const file = join(root, "package.json");
23
+ const { value: pkg, error } = readJson(file);
24
+ if (error) {
25
+ findings.push(finding("DP-000", ERROR, "package.json",
26
+ error === "unreadable" ? "package.json is missing; there is no dependency declaration to check" : `package.json is not valid JSON: ${error}`));
27
+ return { name, findings };
28
+ }
29
+
30
+ for (const [dependency, version] of Object.entries(pkg.dependencies ?? {})) {
31
+ if (!ALLOWED_RUNTIME.has(dependency)) {
32
+ findings.push(finding("DP-001", ERROR, "package.json",
33
+ `runtime dependency "${dependency}" is not on the allowlist; the plugin ships without an install step`));
34
+ continue;
35
+ }
36
+ if (!EXACT_VERSION.test(String(version))) {
37
+ findings.push(finding("DP-002", ERROR, "package.json",
38
+ `dependency "${dependency}" is declared as "${version}"; pin an exact version so every install is the same software`));
39
+ }
40
+ }
41
+
42
+ // A dev dependency is still a dependency someone has to resolve, and this
43
+ // repository deliberately has no build or test toolchain to justify one.
44
+ for (const dependency of Object.keys(pkg.devDependencies ?? {})) {
45
+ findings.push(finding("DP-003", ERROR, "package.json",
46
+ `dev dependency "${dependency}" is declared; the plugin repository carries no toolchain of its own`));
47
+ }
48
+
49
+ const engine = pkg.engines?.node;
50
+ if (typeof engine !== "string" || !engine.trim()) {
51
+ findings.push(finding("DP-004", ERROR, "package.json",
52
+ "engines.node is not declared, so nothing states which Node the plugin requires"));
53
+ } else {
54
+ const major = Number((engine.match(/(\d+)/) ?? [])[1]);
55
+ if (!Number.isFinite(major)) {
56
+ findings.push(finding("DP-004", ERROR, "package.json", `engines.node "${engine}" names no version`));
57
+ } else if (major !== MINIMUM_NODE_MAJOR) {
58
+ findings.push(finding("DP-005", ERROR, "package.json",
59
+ `engines.node "${engine}" does not pin the Node ${MINIMUM_NODE_MAJOR} floor the module contract states`));
60
+ }
61
+ }
62
+
63
+ if (pkg.type !== "module") {
64
+ findings.push(finding("DP-006", ERROR, "package.json",
65
+ `type is "${pkg.type ?? "(absent)"}"; the source is ESM and a .mjs entry point should not depend on that`));
66
+ }
67
+
68
+ return { name, findings };
69
+ }
@@ -0,0 +1,99 @@
1
+ // Template neutrality. The Docs templates and capability fragments are forms a
2
+ // product fills in from its own evidence. A vendor name, a latency number, a
3
+ // cloud region or a compliance regime written into the form becomes an invented
4
+ // fact in every product document generated from it, indistinguishable from one
5
+ // the project actually established.
6
+ //
7
+ // This is a denylist scan. It catches the known ways neutrality is lost; it
8
+ // does not prove neutrality. Placeholders ({{...}}) and HTML comments are
9
+ // exempt, because a placeholder is the form asking, not the form asserting.
10
+
11
+ import { extname, join } from "node:path";
12
+ import { ERROR, WARNING, finding, isDirectory, readText, rel, walk } from "./common.mjs";
13
+
14
+ export const name = "docs-templates";
15
+
16
+ // Naming these here is the enforcement mechanism, not a recommendation. They
17
+ // are all public products. "envx" is absent deliberately: it is a declared
18
+ // external provider of this project, not an invented choice.
19
+ const VENDOR =
20
+ /\b(aws|amazon|azure|gcp|vercel|netlify|heroku|cloudflare|firebase|supabase|stripe|paypal|razorpay|twilio|sendgrid|mailgun|postmark|postgres(?:ql)?|mysql|mariadb|mongodb|dynamodb|redis|kafka|rabbitmq|sqs|auth0|okta|cognito|keycloak|sentry|datadog|posthog|mixpanel|amplitude|segment|launchdarkly|algolia|elasticsearch|next\.?js|react|vue|angular|svelte|drizzle|prisma|hasura|expo)\b/i;
21
+ const LATENCY = /\b\d+(?:\.\d+)?\s?(?:ms|milliseconds|seconds?)\b|\b\d{2}\.\d+\s?%|\b99\.\d+\b/;
22
+ // Region shapes: AWS style (us-east-1), GCP style (europe-west1, us-central1),
23
+ // Azure style (westus2, northeurope).
24
+ const REGION =
25
+ /\b(?:us|eu|ap|sa|ca|me|af)-(?:east|west|north|south|central|northeast|southeast|southwest|northwest)-\d\b|\b(?:us|europe|asia|australia|northamerica|southamerica)-(?:east|west|north|south|central)\d\b|\b(?:east|west|north|south|central)(?:us|europe|asia)\d?\b/i;
26
+ const REGIME = /\b(GDPR|HIPAA|CCPA|CPRA|DPDP|PCI[- ]?DSS|SOC\s?2|ISO\s?27001|FedRAMP|COPPA|FERPA)\b/;
27
+ const ENV_VAR = /\b[A-Z][A-Z0-9]+(?:_[A-Z0-9]+)+\b/;
28
+
29
+ const RULES = [
30
+ { code: "DT-001", severity: ERROR, test: LATENCY, message: "states a concrete latency or availability number" },
31
+ { code: "DT-002", severity: ERROR, test: REGION, message: "names a cloud region" },
32
+ { code: "DT-003", severity: ERROR, test: REGIME, message: "asserts a concrete compliance regime" },
33
+ { code: "DT-004", severity: WARNING, test: ENV_VAR, message: "names a concrete environment variable" },
34
+ { code: "DT-005", severity: ERROR, test: VENDOR, message: "names a vendor or product" },
35
+ ];
36
+
37
+ /**
38
+ * Lint one document line by line, with HTML comments and placeholders removed.
39
+ * Comment state carries across lines because a template header comment spans
40
+ * several, and a naive per-line strip would judge its continuation as prose.
41
+ */
42
+ export function lintText(path, text) {
43
+ const findings = [];
44
+ let inComment = false;
45
+ text.split("\n").forEach((raw, index) => {
46
+ let line = raw;
47
+ if (inComment) {
48
+ const end = line.indexOf("-->");
49
+ if (end === -1) return;
50
+ line = line.slice(end + 3);
51
+ inComment = false;
52
+ }
53
+ line = line.replace(/<!--[\s\S]*?-->/g, "");
54
+ const open = line.indexOf("<!--");
55
+ if (open !== -1) {
56
+ line = line.slice(0, open);
57
+ inComment = true;
58
+ }
59
+ line = line.replace(/\{\{[^}]*\}\}/g, "");
60
+ for (const rule of RULES) {
61
+ if (rule.test.test(line)) {
62
+ findings.push(finding(rule.code, rule.severity, `${path}:${index + 1}`, rule.message));
63
+ }
64
+ }
65
+ });
66
+ return findings;
67
+ }
68
+
69
+ export async function run(root) {
70
+ const findings = [];
71
+ const assets = join(root, "skills", "docs", "assets");
72
+ const templates = join(assets, "templates");
73
+ const fragments = join(assets, "fragments");
74
+
75
+ for (const [label, dir] of [["templates", templates], ["fragments", fragments]]) {
76
+ if (!isDirectory(dir)) {
77
+ findings.push(finding("DT-000", ERROR, rel(root, dir),
78
+ `the Docs ${label} directory is missing; there is nothing to lint`));
79
+ }
80
+ }
81
+ if (findings.length) return { name, findings };
82
+
83
+ const files = [...walk(templates), ...walk(fragments)].filter((file) => extname(file) === ".md");
84
+ if (!files.length) {
85
+ findings.push(finding("DT-000", ERROR, rel(root, assets), "no template or fragment documents found"));
86
+ return { name, findings };
87
+ }
88
+
89
+ for (const file of files) {
90
+ const text = readText(file);
91
+ if (text === null) {
92
+ findings.push(finding("DT-006", ERROR, rel(root, file), "document is unreadable"));
93
+ continue;
94
+ }
95
+ findings.push(...lintText(rel(root, file), text));
96
+ }
97
+
98
+ return { name, findings };
99
+ }
@@ -0,0 +1,78 @@
1
+ // Repository footprint. Superdev must not turn a repository into a database
2
+ // made of small Markdown and JSON tracking files. Tasks, subtasks, assignments,
3
+ // status transitions, evidence, activity, sessions, agents, branches, memory,
4
+ // sync cursors and conflicts live in SQLite and appear in the control center.
5
+ // One file per database row is the failure this rebuild exists to remove.
6
+ //
7
+ // Two signals, both name-shaped, both checkable without opening a file:
8
+ // a file named after a record identifier, and a directory named after an
9
+ // operational record kind that has filled up with data documents. Source
10
+ // modules are never data documents, so a module named tasks/ or memory/ is not
11
+ // a tracking directory and is not reported as one.
12
+
13
+ import { basename, extname } from "node:path";
14
+ import { ERROR, finding, isDirectory, rel, walk } from "./common.mjs";
15
+
16
+ export const name = "footprint";
17
+
18
+ /** Identifier prefixes for records that must never become files. */
19
+ const OPERATIONAL_IDS = /^(TASK|ASG|SH|EV|EVT|SES|AGT|GBR|MEM|ML|PEER|CONF|POS)-\d{3,}$/;
20
+
21
+ /** Directory names that describe operational tracking rather than a component. */
22
+ const OPERATIONAL_DIRS = new Set([
23
+ "tasks", "subtasks", "assignments", "transitions", "status-history",
24
+ "evidence", "events", "activity", "sessions", "agents", "branches",
25
+ "heartbeats", "memory", "sync", "cursors", "conflicts", "revisions",
26
+ "snapshots", "progress",
27
+ ]);
28
+
29
+ /** Extensions that carry data rather than behavior. */
30
+ const DATA_EXTENSIONS = new Set([".md", ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".txt"]);
31
+
32
+ // Two is already a pattern. One data document in a directory that happens to
33
+ // carry an operational name is a specification, not a ledger.
34
+ const TRACKING_THRESHOLD = 2;
35
+
36
+ export async function run(root) {
37
+ const findings = [];
38
+ if (!isDirectory(root)) {
39
+ findings.push(finding("FP-000", ERROR, ".", "the project root does not exist"));
40
+ return { name, findings };
41
+ }
42
+
43
+ const files = walk(root);
44
+ if (!files.length) {
45
+ findings.push(finding("FP-000", ERROR, ".", "the project root holds no files"));
46
+ return { name, findings };
47
+ }
48
+
49
+ const perDirectory = new Map();
50
+
51
+ for (const file of files) {
52
+ const path = rel(root, file);
53
+ const extension = extname(path);
54
+ const stem = basename(path, extension);
55
+
56
+ if (DATA_EXTENSIONS.has(extension) && OPERATIONAL_IDS.test(stem)) {
57
+ findings.push(finding("FP-001", ERROR, path,
58
+ `named after the record ${stem}; operational records live in SQLite, never one file each`));
59
+ }
60
+
61
+ if (!DATA_EXTENSIONS.has(extension)) continue;
62
+ const segments = path.split("/");
63
+ const kind = segments.slice(0, -1).findLast((segment) => OPERATIONAL_DIRS.has(segment));
64
+ if (!kind) continue;
65
+ const directory = segments.slice(0, segments.lastIndexOf(kind) + 1).join("/");
66
+ const seen = perDirectory.get(directory) ?? { kind, count: 0 };
67
+ seen.count += 1;
68
+ perDirectory.set(directory, seen);
69
+ }
70
+
71
+ for (const [directory, { kind, count }] of [...perDirectory].sort()) {
72
+ if (count < TRACKING_THRESHOLD) continue;
73
+ findings.push(finding("FP-002", ERROR, directory,
74
+ `${count} data documents under a directory named ${kind}; this is file-per-record tracking, which belongs in SQLite`));
75
+ }
76
+
77
+ return { name, findings };
78
+ }
@@ -0,0 +1,61 @@
1
+ // Import boundaries. Superdev runs from a plugin directory on someone else's
2
+ // machine with no install step, so a module that imports a package which is not
3
+ // there fails at load time, in their session, with a stack trace instead of a
4
+ // product. Node builtins and relative paths always resolve. One runtime
5
+ // dependency is allowed, and only the single module that owns the connection
6
+ // may reach for it, so swapping the engine stays a one file change.
7
+
8
+ import { join, relative, sep } from "node:path";
9
+ import { ERROR, finding, importSpecifiers, isDirectory, readText, rel, walk } from "./common.mjs";
10
+
11
+ export const name = "imports";
12
+
13
+ /** The one runtime dependency, and the only module permitted to import it. */
14
+ export const RUNTIME_DEPENDENCY = "@tursodatabase/database";
15
+ const DEPENDENCY_OWNER = "src/db/connect.mjs";
16
+
17
+ const SCANNED_ROOTS = ["src", "scripts"];
18
+
19
+ const isRelative = (spec) => spec.startsWith("./") || spec.startsWith("../");
20
+ const isBuiltin = (spec) => spec.startsWith("node:");
21
+
22
+ export async function run(root) {
23
+ const findings = [];
24
+ const present = SCANNED_ROOTS.filter((dir) => isDirectory(join(root, dir)));
25
+ if (!present.length) {
26
+ findings.push(finding("IM-000", ERROR, SCANNED_ROOTS.join(", "),
27
+ "neither src/ nor scripts/ exists; there are no modules to check"));
28
+ return { name, findings };
29
+ }
30
+
31
+ const files = present
32
+ .flatMap((dir) => walk(join(root, dir)))
33
+ .filter((file) => file.endsWith(".mjs") || file.endsWith(".js"));
34
+
35
+ if (!files.length) {
36
+ findings.push(finding("IM-000", ERROR, present.join(", "), "no modules found under src/ or scripts/"));
37
+ return { name, findings };
38
+ }
39
+
40
+ for (const file of files) {
41
+ const text = readText(file);
42
+ if (text === null) continue;
43
+ const path = rel(root, file);
44
+ const owner = relative(root, file).split(sep).join("/") === DEPENDENCY_OWNER;
45
+
46
+ for (const spec of new Set(importSpecifiers(text))) {
47
+ if (isBuiltin(spec) || isRelative(spec)) continue;
48
+ if (spec === RUNTIME_DEPENDENCY) {
49
+ if (!owner) {
50
+ findings.push(finding("IM-002", ERROR, path,
51
+ `imports ${RUNTIME_DEPENDENCY} directly; only ${DEPENDENCY_OWNER} may, so the engine stays replaceable in one place`));
52
+ }
53
+ continue;
54
+ }
55
+ findings.push(finding("IM-001", ERROR, path,
56
+ `imports "${spec}", which is neither a node builtin, a relative path, nor the one allowed runtime dependency`));
57
+ }
58
+ }
59
+
60
+ return { name, findings };
61
+ }