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,115 @@
1
+ // Confidentiality. This project is developed against private material that must
2
+ // never reach the public repository: no absolute home path, no credential, no
3
+ // private project identifier, in source, comments, generated artifacts or
4
+ // examples.
5
+ //
6
+ // The screening rules are the ones the storage boundary already enforces, so a
7
+ // string refused on write cannot arrive through a file instead. The private
8
+ // identifier list lives outside the repository by design and is supplied at run
9
+ // time through SUPERDEV_DENYLIST; findings carry rule ids and locations only,
10
+ // never the matched value, because echoing a denylist entry into a report is
11
+ // itself the leak.
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { hasHomePath, looksSecret } from "../../src/model/screening.mjs";
16
+ import { ERROR, finding, ownedFiles, readText } from "./common.mjs";
17
+
18
+ export const name = "privacy";
19
+
20
+ const DENYLIST_ENV = "SUPERDEV_DENYLIST";
21
+
22
+ /** Registry integrity hashes are the point of a lockfile, not a leak. */
23
+ const LOCKFILES = new Set([
24
+ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "pnpm-lock.yml",
25
+ "yarn.lock", "bun.lock", "Cargo.lock", "Gemfile.lock", "poetry.lock",
26
+ "Pipfile.lock", "uv.lock", "go.sum", "composer.lock",
27
+ ]);
28
+
29
+ /**
30
+ * One file is exempt from the credential heuristic, by name and for one reason:
31
+ * `src/model/screening.mjs` is where that heuristic is defined, so it contains
32
+ * the shapes it looks for and the refusal codes it raises. A scanner that
33
+ * reports its own rule book teaches people to skim its output, which costs more
34
+ * than the finding is worth.
35
+ *
36
+ * Deliberately not a mechanism. There is no wildcard, no directory, no comment
37
+ * pragma and no environment override: adding a second exempt file means editing
38
+ * this line and justifying it here. The exemption covers this rule only; the
39
+ * home-path rule and the denylist still judge the file in full, and the storage
40
+ * boundary still refuses the same strings at run time.
41
+ */
42
+ const DEFINES_THE_CREDENTIAL_RULE = "src/model/screening.mjs";
43
+
44
+ /**
45
+ * The compiled control center, exempt from the credential heuristic for the same
46
+ * narrow reason and no broader one: it is a build artifact, and its source in
47
+ * `ui/src` is scanned in full by this same validator. Minified vendor code trips
48
+ * the heuristic without holding a credential; the observed case was a framework
49
+ * lookup table mapping HTML input type names to booleans, one of which is the
50
+ * name of a credential field followed by a separator and a value, which is the
51
+ * shape the rule looks for.
52
+ *
53
+ * The home-path rule and the denylist still judge this file, because those catch
54
+ * a real leak that minification would carry through unchanged.
55
+ */
56
+ const COMPILED_BUNDLE = "src/service/assets/control-center.html";
57
+
58
+ /** Case-insensitive substrings, one per line, # comments. Never echoed. */
59
+ export function loadDenylist(file) {
60
+ const text = readText(file);
61
+ if (text === null) return [];
62
+ return text
63
+ .split(/\r?\n/)
64
+ .map((line) => line.trim())
65
+ .filter((line) => line && !line.startsWith("#"))
66
+ .map((pattern, index) => ({ id: `PV-DL-${index + 1}`, pattern: pattern.toLowerCase() }));
67
+ }
68
+
69
+ export async function run(root) {
70
+ const findings = [];
71
+ const files = ownedFiles(root);
72
+ if (!files.length) {
73
+ findings.push(finding("PV-000", ERROR, ".", "no project-owned text files found; there is nothing to scan"));
74
+ return { name, findings };
75
+ }
76
+
77
+ // The private identifier list is external by design. When it is configured it
78
+ // must load: a denylist that quietly fails to read is worse than none, because
79
+ // the run then claims a check it did not perform.
80
+ const denylistFile = process.env[DENYLIST_ENV];
81
+ let denylist = [];
82
+ if (denylistFile && !existsSync(denylistFile)) {
83
+ findings.push(finding("PV-003", ERROR, ".",
84
+ `${DENYLIST_ENV} points at a file that does not exist, so private identifiers were not checked`));
85
+ } else if (denylistFile) {
86
+ denylist = loadDenylist(denylistFile);
87
+ }
88
+
89
+ for (const path of files) {
90
+ const text = readText(join(root, path));
91
+ if (text === null) continue;
92
+ const isLockfile = LOCKFILES.has(path.split("/").pop());
93
+ const lower = denylist.length ? text.toLowerCase() : "";
94
+
95
+ text.split("\n").forEach((line, index) => {
96
+ const where = `${path}:${index + 1}`;
97
+ if (hasHomePath(line)) {
98
+ findings.push(finding("PV-001", ERROR, where,
99
+ "absolute home path; it names a machine and it points nowhere on anyone else's"));
100
+ }
101
+ if (!isLockfile && path !== DEFINES_THE_CREDENTIAL_RULE && path !== COMPILED_BUNDLE && looksSecret(line)) {
102
+ findings.push(finding("PV-002", ERROR, where,
103
+ "secret-shaped string; store a reference, never the value"));
104
+ }
105
+ });
106
+
107
+ for (const { id, pattern } of denylist) {
108
+ if (lower.includes(pattern)) {
109
+ findings.push(finding(id, ERROR, path, "matches a private identifier from the external denylist"));
110
+ }
111
+ }
112
+ }
113
+
114
+ return { name, findings };
115
+ }
@@ -0,0 +1,127 @@
1
+ // Do the record-to-record links still point at records that exist?
2
+ //
3
+ // Several link tables are polymorphic: they carry a target_type and a target_id
4
+ // rather than a foreign key, because one column cannot reference six tables. A
5
+ // foreign key is what normally makes a dangling reference impossible, so these
6
+ // tables have no such protection and deleting a record leaves its links behind,
7
+ // silently, pointing at nothing.
8
+ //
9
+ // That is not hypothetical. Removing the data entities that were not database
10
+ // tables left 84 tasks linked to entity identifiers that no longer existed, and
11
+ // nothing reported it: the tasks still listed what they implemented, and the
12
+ // identifier resolved to nothing.
13
+
14
+ import { join } from "node:path";
15
+
16
+ import { ERROR, WARNING, finding } from "./common.mjs";
17
+
18
+ /**
19
+ * The polymorphic link tables, and where each target_type resolves.
20
+ *
21
+ * A target_type missing from this map is reported rather than skipped, so
22
+ * adding a new kind of target cannot quietly opt out of the check.
23
+ */
24
+ const LINK_TABLES = {
25
+ task_contract_links: "target_type",
26
+ decision_links: "target_type",
27
+ memory_links: "target_type",
28
+ // discovery_links joins two discovery items directly, so it carries a real
29
+ // foreign key on both ends and needs no check here.
30
+ };
31
+
32
+ /** Where a target_type name resolves. */
33
+ const TARGET_TABLES = {
34
+ data_entity: "data_entities",
35
+ feature: "features",
36
+ module: "modules",
37
+ workflow: "workflows",
38
+ task: "tasks",
39
+ goal: "goals",
40
+ milestone: "milestones",
41
+ decision: "decisions",
42
+ surface: "surfaces",
43
+ api_operation: "api_operations",
44
+ acceptance_criterion: "feature_acceptance_criteria",
45
+ nfr: "non_functional_requirements",
46
+ workflow_step: "workflow_steps",
47
+ runtime_component: "runtime_components",
48
+ integration: "integrations",
49
+ question: "open_questions",
50
+ risk: "risks",
51
+ document: "documents",
52
+ };
53
+
54
+ export const name = "record-links";
55
+
56
+ export async function run(root) {
57
+ const findings = [];
58
+ const { query } = await import(join(root, "src/db/store.mjs"));
59
+
60
+ let result;
61
+ try {
62
+ result = await query(root, async (db) => {
63
+ const present = new Set(
64
+ (await db.all("SELECT name FROM sqlite_master WHERE type = 'table'")).map((t) => t.name),
65
+ );
66
+ const out = [];
67
+ for (const [table, typeColumn] of Object.entries(LINK_TABLES)) {
68
+ if (!present.has(table)) continue;
69
+ const rows = await db.all(
70
+ `SELECT ${typeColumn} AS target_type, target_id, COUNT(*) AS n
71
+ FROM ${table} GROUP BY ${typeColumn}, target_id`,
72
+ );
73
+ for (const row of rows) {
74
+ const targetTable = TARGET_TABLES[row.target_type];
75
+ if (!targetTable) {
76
+ out.push({ table, unknownType: row.target_type, n: row.n });
77
+ continue;
78
+ }
79
+ if (!present.has(targetTable)) {
80
+ out.push({ table, missingTable: targetTable, type: row.target_type, n: row.n });
81
+ continue;
82
+ }
83
+ const hit = await db.get(
84
+ `SELECT 1 AS ok FROM ${targetTable} WHERE id = ?`, row.target_id,
85
+ );
86
+ if (!hit) out.push({ table, dangling: row.target_id, type: row.target_type, n: row.n });
87
+ }
88
+ }
89
+ return out;
90
+ });
91
+ } catch (error) {
92
+ return {
93
+ name,
94
+ findings: [finding("record-links-unreadable", WARNING, ".superdev",
95
+ `The project database could not be read, so record links were not checked: ${error.message}`)],
96
+ };
97
+ }
98
+
99
+ // One finding per target, not per link row: ten tasks pointing at the same
100
+ // deleted entity is one deletion to undo, not ten problems to read.
101
+ // An unknown target type is one gap in this map however many rows use it,
102
+ // so it collapses to a single finding rather than repeating per target.
103
+ const unknown = new Map();
104
+ for (const item of result) {
105
+ if (!item.unknownType) continue;
106
+ const key = `${item.table}/${item.unknownType}`;
107
+ unknown.set(key, (unknown.get(key) ?? 0) + item.n);
108
+ }
109
+ for (const [key, n] of unknown) {
110
+ const [table, type] = key.split("/");
111
+ findings.push(finding("link-target-type-unknown", WARNING, "scripts/validate/record-links.mjs",
112
+ `${table} has ${n} links to target type "${type}", which this validator does not know how to resolve. Add it to TARGET_TABLES or stop writing it.`));
113
+ }
114
+
115
+ for (const item of result) {
116
+ if (item.unknownType) {
117
+ continue;
118
+ } else if (item.missingTable) {
119
+ findings.push(finding("link-target-table-missing", ERROR, "src/db/migrations",
120
+ `${item.table} links to ${item.type}, which resolves to the table ${item.missingTable} that does not exist.`));
121
+ } else {
122
+ findings.push(finding("link-target-missing", ERROR, ".superdev",
123
+ `${item.table} has ${item.n} link${item.n === 1 ? "" : "s"} to ${item.dangling}, which no longer exists.`));
124
+ }
125
+ }
126
+ return { name, findings };
127
+ }
@@ -0,0 +1,190 @@
1
+ // Do the skills tell an agent to run commands that exist, with flags that exist?
2
+ //
3
+ // A skill is read by an agent that then runs what it says. A skill documenting
4
+ // a command shape the CLI never had does not fail loudly: the agent runs it,
5
+ // the flag is ignored or refused, and the agent spends its time working out why
6
+ // the product will not do what its own documentation promised.
7
+ //
8
+ // That is not hypothetical either. The task skill documented completion as
9
+ // SD task complete <id> --evidence-type ... --result ... --reference ...
10
+ // for as long as task complete has existed, and task complete has never read
11
+ // any of those three flags. There was no command to record evidence at all, so
12
+ // the documented flow could not be followed and no task could be completed.
13
+ //
14
+ // This validator reads the CLI as the source of truth and the skills as claims
15
+ // about it.
16
+
17
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import { ERROR, WARNING, finding, rel } from "./common.mjs";
21
+
22
+ export const name = "skill-commands";
23
+
24
+ /** Flags every command accepts, handled by the runner rather than a command. */
25
+ const GLOBAL_FLAGS = new Set([
26
+ "apply", "json", "help", "root", "actor", "session", "quiet", "verbose", "yes",
27
+ ]);
28
+
29
+ /**
30
+ * The command table, and the flags each command actually reads.
31
+ *
32
+ * Flags are found by reading each command function for ctx.flags.<name> and
33
+ * requireFlag(ctx.flags, "<name>"), which is how every command reads one.
34
+ */
35
+ function cliContract(root) {
36
+ const source = readFileSync(join(root, "src/cli.mjs"), "utf8");
37
+
38
+ // The command table maps a command string to its handler function name.
39
+ const table = source.slice(source.indexOf("const COMMANDS = {"));
40
+ const commands = new Map();
41
+ for (const m of table.matchAll(/(?:"([a-z][a-z ]*)"|([a-z][a-z0-9]*))\s*:\s*(cmd[A-Za-z0-9_]+)/g)) {
42
+ commands.set(m[1] ?? m[2], m[3]);
43
+ }
44
+
45
+ // Some commands read their flags from a table of [flag, field] pairs rather
46
+ // than naming each one, so a handler that loops over such a table accepts
47
+ // every flag in it. Missing this would report a documented flag that works.
48
+ const flagTables = new Map();
49
+ for (const m of source.matchAll(/const ([A-Z][A-Z0-9_]*)\s*=\s*\[([\s\S]*?)\];/g)) {
50
+ const pairs = [...m[2].matchAll(/\[\s*"([a-zA-Z0-9_]+)"\s*,/g)].map((p) => p[1]);
51
+ if (pairs.length) flagTables.set(m[1], pairs);
52
+ }
53
+
54
+ // Each handler's body, from its declaration to the next top level declaration.
55
+ const flagsByHandler = new Map();
56
+ for (const m of source.matchAll(/(?:async )?function (cmd[A-Za-z0-9_]+)\s*\(/g)) {
57
+ const start = m.index;
58
+ const next = source.indexOf("\nasync function ", start + 1);
59
+ const alt = source.indexOf("\nfunction ", start + 1);
60
+ const ends = [next, alt].filter((i) => i > 0);
61
+ const end = ends.length ? Math.min(...ends) : source.length;
62
+ const body = source.slice(start, end);
63
+ const flags = new Set();
64
+ for (const f of body.matchAll(/ctx\.flags\.([A-Za-z0-9_]+)/g)) flags.add(f[1]);
65
+ for (const f of body.matchAll(/requireFlag\(\s*ctx\.flags\s*,\s*"([^"]+)"/g)) flags.add(f[1]);
66
+ for (const f of body.matchAll(/ctx\.flags\[\s*"([^"]+)"\s*\]/g)) flags.add(f[1]);
67
+ for (const [table, names] of flagTables) {
68
+ if (body.includes(table)) for (const n of names) flags.add(n);
69
+ }
70
+ flagsByHandler.set(m[1], flags);
71
+ }
72
+
73
+ return { commands, flagsByHandler };
74
+ }
75
+
76
+ /** Every skill file that instructs an agent. */
77
+ function skillFiles(root) {
78
+ const dir = join(root, "skills");
79
+ if (!existsSync(dir)) return [];
80
+ const out = [];
81
+ const walk = (d) => {
82
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
83
+ const p = join(d, entry.name);
84
+ if (entry.isDirectory()) walk(p);
85
+ else if (entry.name.endsWith(".md")) out.push(p);
86
+ }
87
+ };
88
+ walk(dir);
89
+ return out;
90
+ }
91
+
92
+ export async function run(root) {
93
+ const findings = [];
94
+ if (!existsSync(join(root, "src/cli.mjs"))) return { name, findings };
95
+
96
+ const { commands, flagsByHandler } = cliContract(root);
97
+ if (commands.size === 0) {
98
+ return {
99
+ name,
100
+ findings: [finding("cli-table-unreadable", ERROR, "src/cli.mjs",
101
+ "No command table could be read from the CLI, so skill commands were not checked.")],
102
+ };
103
+ }
104
+
105
+ // Longest first, so "task complete" matches before "task".
106
+ const known = [...commands.keys()].sort((a, b) => b.length - a.length);
107
+
108
+ for (const file of skillFiles(root)) {
109
+ const text = readFileSync(file, "utf8");
110
+ const where = rel(root, file);
111
+
112
+ // Only code carries instructions. A sentence that mentions the product by
113
+ // name is prose, and reading it as a command invented findings about
114
+ // "why is this failing to start?" being an unknown command.
115
+ // Each span is scanned on its own. Joining them let a lone `SD` span run
116
+ // into the next span, so the alias definition read as an invocation.
117
+ // A fenced block keeps its line breaks: each line is its own command, and
118
+ // joining them ran one command's flags into the next.
119
+ //
120
+ // An inline span is unwrapped, because Markdown breaks a long one across
121
+ // lines and requiring a single line missed every command that wrapped.
122
+ const fenced = [...text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)].map((m) => m[1]);
123
+ // Fences are removed before inline spans are read. A single backtick pair
124
+ // matches happily from the third backtick of an opening fence to the first
125
+ // of the closing one, which swallowed a whole block as one inline span and
126
+ // ran every command in it together.
127
+ const withoutFences = text.replace(/```[a-z]*\n[\s\S]*?```/g, "\n");
128
+ const spans = [
129
+ ...fenced,
130
+ ...[...withoutFences.matchAll(/`([^`]+)`/g)].map((m) => m[1].replace(/\s*\n\s*/g, " ")),
131
+ ];
132
+
133
+ // Two shapes carry an invocation: "SD task complete <id>" in a skill, and a
134
+ // bare "task complete <id>" in the command reference tables. The reference
135
+ // is where the flags are documented, so missing it left the canonical list
136
+ // unchecked.
137
+ const invocations = spans.flatMap((span) => {
138
+ const prefixed = [...span.matchAll(/(?:^|\s)(?:SD|superdev)\s+([a-z][a-z0-9 ]*?)(\s*(?:--|<|\[)(?:[^\n]|\\\n)*|\s*$)/g)]
139
+ .map((m) => ({ words: m[1], rest: m[2] || "" }));
140
+ if (prefixed.length > 0) return prefixed;
141
+ // A span is a bare invocation only when it opens with a real command, so
142
+ // prose in backticks is never read as one.
143
+ const bare = span.match(/^([a-z][a-z0-9 ]*?)(\s(?:<|\[|--)[\s\S]*)?$/);
144
+ if (!bare) return [];
145
+ const head = bare[1].trim();
146
+ if (!known.some((c) => head === c || head.startsWith(c + " "))) return [];
147
+ return [{ words: head, rest: bare[2] || "" }];
148
+ });
149
+
150
+ for (const m of invocations) {
151
+ const rest = m.rest;
152
+ const words = m.words.trim();
153
+ const command = known.find((c) => words === c || words.startsWith(c + " "));
154
+
155
+ if (!command) {
156
+ const first = words.split(/\s+/)[0];
157
+ // Only report when the first word is not a command at all, so a
158
+ // subcommand this validator cannot resolve is not reported twice.
159
+ if (!known.some((c) => c === first || c.startsWith(first + " "))) {
160
+ findings.push(finding("skill-command-unknown", ERROR, where,
161
+ `The skill tells an agent to run "${(words + " " + rest.trim()).trim().slice(0, 60)}", and the CLI has no such command.`));
162
+ }
163
+ continue;
164
+ }
165
+
166
+ const flags = flagsByHandler.get(commands.get(command)) ?? new Set();
167
+ for (const f of rest.matchAll(/--([a-zA-Z][a-zA-Z0-9-]*)/g)) {
168
+ const flag = f[1];
169
+ // camelCase in the source, kebab-case in the documentation.
170
+ const camel = flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
171
+ if (GLOBAL_FLAGS.has(flag) || flags.has(flag) || flags.has(camel)) continue;
172
+ findings.push(finding("skill-flag-unknown", ERROR, where,
173
+ `The skill documents "${command} --${flag}", and that command never reads --${flag}.`));
174
+ }
175
+ }
176
+ }
177
+
178
+ // One finding per distinct claim: a flag documented in four places is one
179
+ // wrong instruction repeated, but each file still needs fixing, so they are
180
+ // kept separate and only exact duplicates within a file are collapsed.
181
+ const seen = new Set();
182
+ const unique = findings.filter((f) => {
183
+ const key = `${f.path}|${f.message}`;
184
+ if (seen.has(key)) return false;
185
+ seen.add(key);
186
+ return true;
187
+ });
188
+
189
+ return { name, findings: unique };
190
+ }
@@ -0,0 +1,112 @@
1
+ // Skill packaging rules. A skill is loaded by name from its directory and its
2
+ // body is read into a context window, so the name has to match the directory,
3
+ // the description has to fit the field a host reserves for it, and every path
4
+ // the prose names has to exist inside the installed plugin.
5
+
6
+ import { existsSync, readdirSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+ import { ERROR, finding, isDirectory, parseFrontmatter, readText, rel } from "./common.mjs";
9
+
10
+ export const name = "skills";
11
+
12
+ const MAX_DESCRIPTION = 1024;
13
+ const MAX_LINES = 500;
14
+
15
+ // `node some/path.mjs` in skill prose silently depends on the working
16
+ // directory. Installed under a plugin root, that path points nowhere.
17
+ const CWD_DEPENDENT = /node\s+(?!"?\$\{CLAUDE_PLUGIN_ROOT\})"?[A-Za-z_.][^"\s]*\.mjs/;
18
+ // Backticked paths a skill claims to ship. Templated examples are not claims.
19
+ const RESOURCE_MENTION = /`(\$\{CLAUDE_PLUGIN_ROOT\}\/)?((?:skills|references|assets|scripts)\/[A-Za-z0-9._/{}-]+\.(?:md|mjs|json|sql))`/g;
20
+
21
+ function markdownFiles(dir) {
22
+ if (!isDirectory(dir)) return [];
23
+ return readdirSync(dir, { withFileTypes: true })
24
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
25
+ .map((entry) => join(dir, entry.name))
26
+ .sort();
27
+ }
28
+
29
+ function lintOne(root, skillDir, findings) {
30
+ const directory = basename(skillDir);
31
+ const skillFile = join(skillDir, "SKILL.md");
32
+ const path = rel(root, skillFile);
33
+
34
+ const text = readText(skillFile);
35
+ if (text === null) {
36
+ findings.push(finding("SK-001", ERROR, path, "SKILL.md is missing or unreadable"));
37
+ return;
38
+ }
39
+
40
+ const lines = text.split("\n").length;
41
+ if (lines > MAX_LINES) {
42
+ findings.push(finding("SK-002", ERROR, path,
43
+ `SKILL.md is ${lines} lines, over the ${MAX_LINES} line budget; move detail into references/`));
44
+ }
45
+
46
+ const frontmatter = parseFrontmatter(text);
47
+ if (!frontmatter) {
48
+ findings.push(finding("SK-003", ERROR, path, "frontmatter is missing or does not parse"));
49
+ return;
50
+ }
51
+ if ((frontmatter.name ?? "") !== directory) {
52
+ findings.push(finding("SK-004", ERROR, path,
53
+ `frontmatter name "${frontmatter.name ?? ""}" does not match directory "${directory}"`));
54
+ }
55
+ const description = frontmatter.description ?? "";
56
+ if (!description.trim()) {
57
+ findings.push(finding("SK-005", ERROR, path, "description is missing; a host cannot decide when to load this skill"));
58
+ } else if (description.length > MAX_DESCRIPTION) {
59
+ findings.push(finding("SK-005", ERROR, path,
60
+ `description is ${description.length} characters, over the ${MAX_DESCRIPTION} character limit`));
61
+ }
62
+
63
+ // A bare `references/...` mention only resolves when the skill bundles its own.
64
+ if (/`references\//.test(text) && !isDirectory(join(skillDir, "references"))) {
65
+ findings.push(finding("SK-006", ERROR, path,
66
+ "names a bare references/ path but ships no references/ directory; use ${CLAUDE_PLUGIN_ROOT}/references/"));
67
+ }
68
+
69
+ const bodies = [skillFile, ...markdownFiles(join(skillDir, "references"))];
70
+ for (const file of bodies) {
71
+ const body = readText(file);
72
+ if (body === null) continue;
73
+ const bodyPath = rel(root, file);
74
+ body.split("\n").forEach((line, index) => {
75
+ if (CWD_DEPENDENT.test(line)) {
76
+ findings.push(finding("SK-007", ERROR, `${bodyPath}:${index + 1}`,
77
+ "runs a script by working-directory-relative path; anchor it to ${CLAUDE_PLUGIN_ROOT}"));
78
+ }
79
+ });
80
+ for (const match of body.matchAll(RESOURCE_MENTION)) {
81
+ const [, pluginRoot, mentioned] = match;
82
+ if (mentioned.includes("{{")) continue;
83
+ const target = pluginRoot ? join(root, mentioned) : join(skillDir, mentioned);
84
+ if (!existsSync(target)) {
85
+ findings.push(finding("SK-008", ERROR, bodyPath, `names ${mentioned}, which does not exist`));
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ export async function run(root) {
92
+ const findings = [];
93
+ const skillsDir = join(root, "skills");
94
+ if (!isDirectory(skillsDir)) {
95
+ findings.push(finding("SK-000", ERROR, "skills", "there is no skills directory; the skill validator has nothing to inspect"));
96
+ return { name, findings };
97
+ }
98
+
99
+ const directories = readdirSync(skillsDir, { withFileTypes: true })
100
+ .filter((entry) => entry.isDirectory())
101
+ .map((entry) => join(skillsDir, entry.name))
102
+ .sort();
103
+
104
+ if (!directories.length) {
105
+ findings.push(finding("SK-000", ERROR, "skills", "the skills directory is empty; the skill validator has nothing to inspect"));
106
+ return { name, findings };
107
+ }
108
+
109
+ for (const dir of directories) lintOne(root, dir, findings);
110
+
111
+ return { name, findings };
112
+ }
@@ -0,0 +1,88 @@
1
+ // Can the specification actually be verified, or only agreed with?
2
+ //
3
+ // A quality requirement that states no target and no measurement method cannot
4
+ // be checked by anyone. It reads like a requirement and behaves like a wish:
5
+ // nobody can say whether the product meets it, so nobody can say whether the
6
+ // work behind it is done.
7
+ //
8
+ // This is not a style point. Every task derived from a quality requirement is
9
+ // given the verification "Measure NFR-xxxx using its declared measurement
10
+ // method and record the value, not an impression". When no requirement declares
11
+ // one, those tasks state a verification that cannot be performed, and they sit
12
+ // open forever with nobody able to say why.
13
+ //
14
+ // The depth gate checks that requirements of certain categories exist before a
15
+ // feature is accepted. It does not check that any of them can be measured, so
16
+ // features were accepted carrying requirements that could never be verified.
17
+ // This reports that rather than refusing it: refusing would retroactively
18
+ // invalidate every accepted feature, which is the owner's call and not a
19
+ // validator's.
20
+ //
21
+ // DEC-0021 records the rule this enforces. A requirement that genuinely cannot
22
+ // be measured here is not a defect as long as it says so, so a stated reason
23
+ // counts as declared and only silence is reported.
24
+
25
+ import { join } from "node:path";
26
+
27
+ import { WARNING, finding } from "./common.mjs";
28
+
29
+ export const name = "specification";
30
+
31
+ const blank = (v) => v === null || v === undefined || String(v).trim() === "";
32
+
33
+ export async function run(root) {
34
+ const findings = [];
35
+ const { query } = await import(join(root, "src/db/store.mjs"));
36
+
37
+ let data;
38
+ try {
39
+ data = await query(root, async (db) => ({
40
+ nfrs: await db.all(
41
+ `SELECT n.id, n.category, n.requirement, n.target, n.measurement_method, n.feature_id,
42
+ f.name AS feature, f.status AS feature_status
43
+ FROM non_functional_requirements n
44
+ LEFT JOIN features f ON f.id = n.feature_id
45
+ ORDER BY n.id`),
46
+ steps: await db.all(
47
+ "SELECT id, action, expected_result FROM workflow_steps ORDER BY id"),
48
+ }));
49
+ } catch (error) {
50
+ return {
51
+ name,
52
+ findings: [finding("specification-unreadable", WARNING, ".superdev",
53
+ `The project database could not be read, so the specification was not checked: ${error.message}`)],
54
+ };
55
+ }
56
+
57
+ const unmeasurable = data.nfrs.filter((n) => blank(n.target) && blank(n.measurement_method));
58
+ const declaredUnmeasurable = data.nfrs.filter(
59
+ (n) => !blank(n.measurement_method) && /^Not measurable in this repository/.test(String(n.measurement_method)));
60
+ if (unmeasurable.length > 0) {
61
+ // One finding for the whole population. Ninety-nine identical lines say the
62
+ // same thing ninety-nine times and bury everything else in the report.
63
+ const accepted = unmeasurable.filter((n) => n.feature_status === "accepted").length;
64
+ findings.push(finding("quality-requirement-unmeasurable", WARNING, ".superdev",
65
+ `${unmeasurable.length} of ${data.nfrs.length} quality requirements declare neither a target nor a measurement method, and do not say why, so nothing can show whether the product meets them. ${accepted} belong to features that are already accepted. First: ${unmeasurable.slice(0, 3).map((n) => n.id).join(", ")}.`));
66
+ }
67
+
68
+ // A requirement with a target and no way to read it is the same problem in a
69
+ // smaller form, and worth naming separately because the fix differs.
70
+ for (const n of data.nfrs) {
71
+ if (blank(n.target) || !blank(n.measurement_method)) continue;
72
+ findings.push(finding("quality-requirement-has-no-method", WARNING, ".superdev",
73
+ `${n.id} states the target "${String(n.target).slice(0, 60)}" and no way to measure it.`));
74
+ }
75
+
76
+ if (declaredUnmeasurable.length > 0) {
77
+ findings.push(finding("quality-requirement-declared-unmeasurable", WARNING, ".superdev",
78
+ `${declaredUnmeasurable.length} quality requirements say plainly that nothing here can measure them. That is honest rather than broken, and it is the number to watch: ${declaredUnmeasurable.slice(0, 3).map((n) => n.id).join(", ")}.`));
79
+ }
80
+
81
+ for (const s of data.steps) {
82
+ if (!blank(s.expected_result)) continue;
83
+ findings.push(finding("workflow-step-has-no-expected-result", WARNING, ".superdev",
84
+ `${s.id} says to "${String(s.action).slice(0, 60)}" and never says what should happen, so following it proves nothing.`));
85
+ }
86
+
87
+ return { name, findings };
88
+ }