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,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Complete-history confidentiality scanner. Where scan.mjs covers the working
4
+ * tree and the git index, this covers ALL of git history: every reachable object
5
+ * (blob, commit, annotated tag), every historical path a blob ever had, commit
6
+ * and tag MESSAGES, and symlink target blobs (a symlink is a blob whose content
7
+ * is its target string, so byte-scanning every blob covers it).
8
+ *
9
+ * Guarantees:
10
+ * - Never silently skips: an unreadable/oversize object yields a SCAN-UNREADABLE
11
+ * P0 finding (fail closed), never a silent pass.
12
+ * - Reuses the exact scanner rules and external denylist (no policy drift).
13
+ * - Byte-scans binary blobs (denylist / abs-home path / non-allowlisted email /
14
+ * key markers survive an embedded NUL); text blobs get the full content scan.
15
+ * - History is immutable: a historical binary-in-a-text-extension blob (e.g. the
16
+ * pre-fix ingest.mjs) is STILL byte-scanned; only its BIN-TEXT *classification*
17
+ * finding may be suppressed via an exact (object-id, path) exception, which is
18
+ * documented and never suppresses a denylist/path/email/key result.
19
+ * - Denylist values are never echoed.
20
+ *
21
+ * Exit codes: 0 clean, 1 findings, 2 usage/environment error.
22
+ */
23
+ import { parseArgs } from "node:util";
24
+ import { execFileSync } from "node:child_process";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { classifyExtension, bufferIsBinary, loadDenylist, scanBytes, scanContent, redactText, loadAllowBinary } from "./scan.mjs";
28
+
29
+ const USAGE = `Usage: node scan-history.mjs --root <repo> [--denylist <file>] [--allow-binary <file>] [--history-allow <file>] [--json] [--out <file>]
30
+ Scans every reachable git object, path, and commit/tag message across ALL history.`;
31
+ const MAX_BLOB = 512 * 1024 * 1024; // bounded read; overflow fails closed, never silently skips
32
+
33
+ function git(root, args, opts = {}) {
34
+ return execFileSync("git", ["-C", root, ...args], { maxBuffer: MAX_BLOB, ...opts });
35
+ }
36
+
37
+ /** Load the (oid, path) BIN-TEXT exceptions: one "oid path" per line, # comments. */
38
+ export function loadHistoryAllow(file) {
39
+ if (!file || !fs.existsSync(file)) return new Set();
40
+ return new Set(
41
+ fs.readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
42
+ );
43
+ }
44
+
45
+ /** Map every blob oid to the set of historical paths it ever appeared under. */
46
+ function blobPaths(root) {
47
+ const out = git(root, ["rev-list", "--all", "--objects"], { encoding: "utf8" });
48
+ const map = new Map();
49
+ for (const line of out.split("\n")) {
50
+ if (!line) continue;
51
+ const sp = line.indexOf(" ");
52
+ if (sp === -1) continue; // commits/tags have no path
53
+ const oid = line.slice(0, sp), p = line.slice(sp + 1);
54
+ if (!map.has(oid)) map.set(oid, new Set());
55
+ map.get(oid).add(p);
56
+ }
57
+ return map;
58
+ }
59
+
60
+ /** The set of REACHABLE object ids (from all refs, including annotated tag
61
+ * objects). Dangling/unreachable objects - e.g. a transient staged blob that was
62
+ * edited before commit and will be garbage-collected - are not part of the
63
+ * history that can be published, so they are out of scope. */
64
+ function reachableOids(root) {
65
+ const set = new Set();
66
+ for (const line of git(root, ["rev-list", "--all", "--objects"], { encoding: "utf8" }).split("\n")) {
67
+ if (line) set.add(line.split(" ")[0]);
68
+ }
69
+ for (const line of git(root, ["for-each-ref", "--format=%(objecttype) %(objectname)", "refs/tags"], { encoding: "utf8" }).split("\n")) {
70
+ if (line.startsWith("tag ")) set.add(line.slice(4).trim()); // annotated tag objects carry a message
71
+ }
72
+ return set;
73
+ }
74
+
75
+ /** Every REACHABLE object, with type. */
76
+ function allObjects(root) {
77
+ const reachable = reachableOids(root);
78
+ const out = git(root, ["cat-file", "--batch-all-objects", "--unordered", "--batch-check=%(objecttype) %(objectname)"], { encoding: "utf8" });
79
+ return out.split("\n").filter(Boolean).map((l) => { const [type, oid] = l.split(" "); return { type, oid }; }).filter((o) => reachable.has(o.oid));
80
+ }
81
+
82
+ /** Message body of a commit/tag object (everything after the first blank line). */
83
+ function objectMessage(root, oid) {
84
+ const raw = git(root, ["cat-file", oid.startsWith("tag:") ? "tag" : "commit", oid.replace(/^tag:/, "")], { encoding: "utf8" });
85
+ const blank = raw.indexOf("\n\n");
86
+ return blank === -1 ? "" : raw.slice(blank + 2);
87
+ }
88
+
89
+ export function runScanHistory({ root, denylistPath, allowBinaryPath, historyAllowPath, allowBinary }) {
90
+ // Confirm this is a git repository up front; a non-repo is an environment error.
91
+ try { git(root, ["rev-parse", "--git-dir"], { encoding: "utf8" }); }
92
+ catch { throw new Error("not a git repository (or git unavailable)"); }
93
+
94
+ const denylist = denylistPath ? loadDenylist(denylistPath) : [];
95
+ const allow = allowBinary ?? loadAllowBinary(allowBinaryPath);
96
+ const historyAllow = loadHistoryAllow(historyAllowPath);
97
+ const findings = [];
98
+ const paths = blobPaths(root);
99
+ const objects = allObjects(root);
100
+ let blobs = 0, commits = 0, tags = 0;
101
+
102
+ for (const { type, oid } of objects) {
103
+ if (type === "commit") {
104
+ commits++;
105
+ // Author/committer emails live in the header (not the message body) and are
106
+ // scanned as ordinary allowlisted emails; the message body is scanned for
107
+ // denylist entries, absolute home paths, non-allowlisted emails, and keys.
108
+ let msg;
109
+ try { msg = objectMessage(root, oid); }
110
+ catch (e) { findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: `commit:${oid}`, line: 0, detail: `commit unreadable (${e.code ?? "err"}) - fail closed` }); continue; }
111
+ findings.push(...scanBytes(`commit-message:${oid}`, Buffer.from(msg, "utf8"), denylist));
112
+ } else if (type === "tag") {
113
+ tags++;
114
+ let msg;
115
+ try { msg = objectMessage(root, `tag:${oid}`); }
116
+ catch (e) { findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: `tag:${oid}`, line: 0, detail: `tag unreadable (${e.code ?? "err"}) - fail closed` }); continue; }
117
+ findings.push(...scanBytes(`tag-message:${oid}`, Buffer.from(msg, "utf8"), denylist));
118
+ } else if (type === "blob") {
119
+ blobs++;
120
+ let buf;
121
+ try { buf = git(root, ["cat-file", "blob", oid]); }
122
+ catch (e) { findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: `blob:${oid}`, line: 0, detail: `blob unscannable (${e.code ?? "err"}) - fail closed` }); continue; }
123
+ const names = [...(paths.get(oid) ?? [`blob:${oid}`])];
124
+ const label = names[0];
125
+ const binary = bufferIsBinary(buf);
126
+ // Content scan once per blob (same bytes regardless of path).
127
+ if (binary) findings.push(...scanBytes(label, buf, denylist).map((f) => ({ ...f, oid })));
128
+ else findings.push(...scanContent(label, buf.toString("utf8"), denylist).map((f) => ({ ...f, oid })));
129
+ // Classification per historical path (extension depends on the name).
130
+ if (binary) for (const name of names) {
131
+ const cls = classifyExtension(name.split(path.sep).join("/"));
132
+ if (cls === "text") {
133
+ if (!historyAllow.has(`${oid} ${name}`)) // documented, object-scoped exception; content still byte-scanned above
134
+ findings.push({ ruleId: "BIN-TEXT", severity: "P0", file: name, line: 0, oid, detail: "historical text-expected file is binary (leak-scan blind spot)" });
135
+ } else if (cls === "unknown" && !allow.has(name)) {
136
+ findings.push({ ruleId: "BIN-UNCLASSIFIED", severity: "P0", file: name, line: 0, oid, detail: "historical unclassified binary" });
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ const safe = findings.map((f) => ({ ...f, file: redactText(f.file, denylist) }));
143
+ const counts = { P0: findings.filter((f) => f.severity === "P0").length, P1: findings.filter((f) => f.severity === "P1").length };
144
+ return { version: 1, mode: "history", root: redactText(path.resolve(root), denylist), stats: { objects: objects.length, blobs, commits, tags }, rulesLoaded: { denylist: denylist.length }, counts, findings: safe };
145
+ }
146
+
147
+ function main() {
148
+ let args;
149
+ try {
150
+ args = parseArgs({ options: { root: { type: "string", default: "." }, denylist: { type: "string" }, "allow-binary": { type: "string" }, "history-allow": { type: "string" }, json: { type: "boolean", default: false }, out: { type: "string" }, help: { type: "boolean", default: false } } }).values;
151
+ } catch (e) { console.error(String(e.message ?? e)); console.error(USAGE); process.exit(2); }
152
+ if (args.help) { console.log(USAGE); process.exit(0); }
153
+ let report;
154
+ try {
155
+ report = runScanHistory({ root: args.root, denylistPath: args.denylist, allowBinaryPath: args["allow-binary"], historyAllowPath: args["history-allow"] });
156
+ } catch (e) { console.error(String(e.message ?? e)); process.exit(2); }
157
+ if (args.json) console.log(JSON.stringify(report, null, 2));
158
+ else console.log(`history scan - ${report.findings.length} finding(s), P0=${report.counts.P0} P1=${report.counts.P1} over ${report.stats.objects} objects (${report.stats.blobs} blobs, ${report.stats.commits} commits, ${report.stats.tags} tags)` + report.findings.map((f) => `\n [${f.severity}] ${f.ruleId} ${f.file}${f.oid ? ` @${f.oid.slice(0, 10)}` : ""}`).join(""));
159
+ if (args.out) fs.writeFileSync(args.out, JSON.stringify(report, null, 2) + "\n");
160
+ process.exit(report.counts.P0 + report.counts.P1 > 0 ? 1 : 0);
161
+ }
162
+
163
+ if (process.argv[1] && import.meta.url === new URL(`file://${fs.realpathSync(process.argv[1])}`).href) main();
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Privacy/leak scanner. See references/confidentiality.md for the policy.
4
+ *
5
+ * Contract:
6
+ * - reads only; writes nothing unless --out is explicitly provided
7
+ * - denylist entries are NEVER echoed into output (findings carry rule ids only)
8
+ * - machine-readable JSON on stdout with --json
9
+ * - exit 0 clean, 1 findings, 2 usage error
10
+ *
11
+ * Binary policy (fail-closed): binary files are NEVER silently skipped.
12
+ * - A file with a text-expected extension that is binary is a P0 finding
13
+ * (BIN-TEXT) - this closes the NUL-byte blind spot.
14
+ * - Every binary file (whatever its extension) is byte-scanned for denylist
15
+ * entries and byte-searchable P0 rules (paths/emails/keys), matched over a
16
+ * latin1 decode so ASCII byte sequences are found across embedded NULs.
17
+ * - A binary file that is neither text-expected nor a known/allowlisted asset
18
+ * is a P0 finding (BIN-UNCLASSIFIED). Legitimate binary assets pass only via
19
+ * a known-binary extension or the documented allowlist file.
20
+ */
21
+ import { parseArgs } from "node:util";
22
+ import { execFileSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { pathToFileURL } from "node:url";
26
+
27
+ const USAGE = `Usage: node scan.mjs [options]
28
+ --denylist <path> external denylist file (one case-insensitive substring per line, # comments)
29
+ --root <path> directory to scan (default: current directory)
30
+ --staged scan only files staged in git (contents from the index)
31
+ --allow-binary <p> newline list of repo-relative legitimate binary asset paths
32
+ --json machine-readable JSON on stdout
33
+ --out <path> also write the report to this exact path (never writes otherwise)
34
+ --help show this help`;
35
+
36
+ const SKIP_DIRS = new Set([
37
+ ".git", "node_modules", ".impeccable", ".superdev",
38
+ ".next", ".turbo", ".cache", "coverage",
39
+ ]);
40
+ // Extensions whose files MUST be text - a binary one is a P0 leak-vector (BIN-TEXT).
41
+ export const TEXT_EXTENSIONS = new Set([
42
+ ".mjs", ".cjs", ".js", ".ts", ".mts", ".cts", ".jsx", ".tsx", ".vue", ".svelte",
43
+ ".json", ".jsonc", ".md", ".markdown", ".yaml", ".yml", ".sh", ".bash", ".txt",
44
+ ".html", ".css", ".xml", ".toml", ".ini", ".cfg", ".conf", ".env", ".gitignore",
45
+ ".editorconfig", ".sql", ".py", ".rb", ".go", ".rs", ".java", ".c", ".h", ".cpp",
46
+ ]);
47
+ // Extensions that are legitimately binary assets - still byte-scanned, never BIN-TEXT.
48
+ export const KNOWN_BINARY_EXTENSIONS = new Set([
49
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".woff", ".woff2", ".ttf",
50
+ ".otf", ".eot", ".zip", ".gz", ".tar", ".wasm", ".mp3", ".mp4", ".mov", ".bin",
51
+ ]);
52
+ export function classifyExtension(relPosix) {
53
+ const ext = relPosix.slice(relPosix.lastIndexOf("."));
54
+ if (TEXT_EXTENSIONS.has(ext)) return "text";
55
+ if (KNOWN_BINARY_EXTENSIONS.has(ext)) return "known-binary";
56
+ return "unknown";
57
+ }
58
+ export function bufferIsBinary(buf) {
59
+ const n = Math.min(buf.length, 8000);
60
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
61
+ return false;
62
+ }
63
+ const EMAIL_ALLOWED = /@(?:[a-z0-9.-]*\.)?(?:example\.(?:com|org|net)|anthropic\.com)$|\.(?:invalid|test|example|localhost)$/i;
64
+ const RULES = {
65
+ ABS_HOME: { id: "ABS-HOME", severity: "P0", re: /\/(?:Users|home)\/[A-Za-z0-9_-]+\//g },
66
+ EMAIL: { id: "EMAIL", severity: "P0", re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g },
67
+ KEY: { id: "KEY", severity: "P0", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
68
+ HIENT: { id: "HIENT", severity: "P1", re: /[A-Za-z0-9+/=_-]{40,}/g },
69
+ };
70
+
71
+ // Integrity hashes are the purpose of dependency lockfiles. Treating every
72
+ // registry checksum as a possible secret makes the scanner unusable on a real
73
+ // brownfield project (a normal pnpm lockfile can produce hundreds of findings).
74
+ // Only the HIENT heuristic is skipped: denylist matches, absolute paths, emails,
75
+ // private-key markers, binary classification, and env-file rules still apply.
76
+ const DEPENDENCY_LOCKFILES = new Set([
77
+ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "pnpm-lock.yml",
78
+ "yarn.lock", "bun.lock", "composer.lock", "Cargo.lock", "Gemfile.lock",
79
+ "poetry.lock", "Pipfile.lock", "uv.lock", "go.sum", "mix.lock", "Podfile.lock",
80
+ "Package.resolved",
81
+ ]);
82
+
83
+ /**
84
+ * The compiled control center, skipped for the same reason and no broader one.
85
+ * It is minified build output, so it is one enormous line of mangled
86
+ * identifiers that trips an entropy heuristic by construction, and its source
87
+ * under `ui/` is scanned in full. Skipping the heuristic here keeps the release
88
+ * gate meaningful; a scanner that always reports one finding is one people stop
89
+ * reading.
90
+ *
91
+ * As with the lockfiles above, only HIENT is skipped. The denylist, absolute
92
+ * paths, emails, private-key markers and binary classification still judge this
93
+ * file, because minification would carry a real leak through unchanged.
94
+ */
95
+ const DERIVED_BUNDLES = new Set(["src/service/assets/control-center.html"]);
96
+
97
+ export function highEntropyHeuristicApplies(relFile) {
98
+ const posix = relFile.replaceAll("\\", "/");
99
+ if (DERIVED_BUNDLES.has(posix)) return false;
100
+ return !DEPENDENCY_LOCKFILES.has(path.posix.basename(posix));
101
+ }
102
+
103
+ export function loadDenylist(file) {
104
+ const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
105
+ return lines
106
+ .map((l) => l.trim())
107
+ .filter((l) => l && !l.startsWith("#"))
108
+ .map((pattern, i) => ({ id: `DL-${i + 1}`, pattern: pattern.toLowerCase() }));
109
+ }
110
+
111
+ function shannonEntropy(s) {
112
+ const freq = {};
113
+ for (const ch of s) freq[ch] = (freq[ch] ?? 0) + 1;
114
+ return -Object.values(freq).reduce((acc, n) => {
115
+ const p = n / s.length;
116
+ return acc + p * Math.log2(p);
117
+ }, 0);
118
+ }
119
+
120
+ function isBinary(buf) {
121
+ const n = Math.min(buf.length, 8000);
122
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
123
+ return false;
124
+ }
125
+
126
+ /** Byte-searchable P0 rules over a latin1 decode (1:1 byte↔char for ASCII), so
127
+ * denylist entries, paths, emails, and key markers are caught even inside a
128
+ * binary blob or after an embedded NUL. Never echoes matched values. */
129
+ export function scanBytes(relFile, buf, denylist) {
130
+ const findings = [];
131
+ const latin1 = buf.toString("latin1");
132
+ const lower = latin1.toLowerCase();
133
+ for (const { id, pattern } of denylist) {
134
+ if (lower.includes(pattern)) findings.push({ ruleId: id, severity: "P0", file: relFile, line: 0 });
135
+ }
136
+ if (RULES.ABS_HOME.re.test(latin1)) findings.push({ ruleId: RULES.ABS_HOME.id, severity: "P0", file: relFile, line: 0 });
137
+ RULES.ABS_HOME.re.lastIndex = 0;
138
+ for (const m of latin1.matchAll(RULES.EMAIL.re)) {
139
+ if (!EMAIL_ALLOWED.test(m[0])) findings.push({ ruleId: RULES.EMAIL.id, severity: "P0", file: relFile, line: 0 });
140
+ }
141
+ if (RULES.KEY.re.test(latin1)) findings.push({ ruleId: RULES.KEY.id, severity: "P0", file: relFile, line: 0 });
142
+ RULES.KEY.re.lastIndex = 0;
143
+ return findings;
144
+ }
145
+
146
+ export function scanContent(relFile, text, denylist) {
147
+ const findings = [];
148
+ const lines = text.split("\n");
149
+ const lowerLines = lines.map((l) => l.toLowerCase());
150
+ const scanHighEntropy = highEntropyHeuristicApplies(relFile);
151
+
152
+ for (const { id, pattern } of denylist) {
153
+ lowerLines.forEach((l, i) => {
154
+ if (l.includes(pattern)) findings.push({ ruleId: id, severity: "P0", file: relFile, line: i + 1 });
155
+ });
156
+ if (relFile.toLowerCase().includes(pattern))
157
+ findings.push({ ruleId: id, severity: "P0", file: relFile, line: 0 });
158
+ }
159
+
160
+ lines.forEach((l, i) => {
161
+ if (RULES.ABS_HOME.re.test(l)) findings.push({ ruleId: RULES.ABS_HOME.id, severity: "P0", file: relFile, line: i + 1 });
162
+ RULES.ABS_HOME.re.lastIndex = 0;
163
+
164
+ for (const m of l.matchAll(RULES.EMAIL.re)) {
165
+ if (!EMAIL_ALLOWED.test(m[0])) findings.push({ ruleId: RULES.EMAIL.id, severity: "P0", file: relFile, line: i + 1 });
166
+ }
167
+
168
+ if (RULES.KEY.re.test(l)) findings.push({ ruleId: RULES.KEY.id, severity: "P0", file: relFile, line: i + 1 });
169
+ RULES.KEY.re.lastIndex = 0;
170
+
171
+ if (scanHighEntropy) {
172
+ for (const m of l.matchAll(RULES.HIENT.re)) {
173
+ // Entropy heuristic; thresholds change only alongside fixture updates proving the change.
174
+ if (m[0].length >= 40 && shannonEntropy(m[0]) > 4.5 && /[A-Z]/.test(m[0]) && /[a-z]/.test(m[0]) && /[0-9]/.test(m[0])) {
175
+ findings.push({ ruleId: RULES.HIENT.id, severity: "P1", file: relFile, line: i + 1 });
176
+ }
177
+ }
178
+ }
179
+ });
180
+
181
+ if (/(^|\/)\.env(\.|rc$|$)/.test(relFile)) findings.push({ ruleId: "ENVFILE", severity: "P0", file: relFile, line: 0 });
182
+
183
+ return findings;
184
+ }
185
+
186
+ function* walk(dir, root) {
187
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
188
+ const full = path.join(dir, entry.name);
189
+ if (entry.isSymbolicLink()) {
190
+ // Never skip silently: a symlink target can carry an absolute home path,
191
+ // username, or internal host. Yield it so its target is scanned.
192
+ yield { rel: path.relative(root, full), symlink: true };
193
+ continue;
194
+ }
195
+ if (entry.isDirectory()) {
196
+ if (!SKIP_DIRS.has(entry.name)) yield* walk(full, root);
197
+ } else if (entry.isFile()) {
198
+ yield path.relative(root, full);
199
+ }
200
+ }
201
+ }
202
+
203
+ function stagedFiles(root) {
204
+ const out = execFileSync("git", ["-C", root, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"], { encoding: "utf8" });
205
+ return out.split("\0").filter(Boolean);
206
+ }
207
+
208
+ /**
209
+ * A repository scan covers exactly what can enter version control: tracked and
210
+ * non-ignored untracked files. Build caches contain machine paths, integrity
211
+ * hashes, compiled third-party code, and binary archives; walking them produces
212
+ * noise while saying nothing about what the repository can publish.
213
+ *
214
+ * Fall back to the conservative filesystem walk for non-git directories and
215
+ * subdirectory roots, which keeps standalone fixture/scaffold scanning useful.
216
+ */
217
+ function treeEntries(root) {
218
+ try {
219
+ const quietGit = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] };
220
+ const top = execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], quietGit).trim();
221
+ if (fs.realpathSync(top) !== fs.realpathSync(root)) throw new Error("root is not repository top");
222
+ const out = execFileSync("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], quietGit);
223
+ return out.split("\0").filter(Boolean).flatMap((rel) => {
224
+ const full = path.join(root, rel);
225
+ if (!fs.existsSync(full)) return []; // deleted tracked content cannot leak from the tree
226
+ return [{ rel, symlink: fs.lstatSync(full).isSymbolicLink() }];
227
+ });
228
+ } catch {
229
+ return [...walk(root, root)].map((e) => (typeof e === "string" ? { rel: e } : e));
230
+ }
231
+ }
232
+
233
+ /** Denylist entries must never appear in output - including inside file paths.
234
+ * Uses a write cursor so an inserted `[DL-n]` marker is never re-scanned
235
+ * (a denylist entry like "dl" could otherwise match its own marker and loop). */
236
+ export function redactText(text, denylist) {
237
+ let out = text;
238
+ for (const { id, pattern } of denylist) {
239
+ let result = "";
240
+ let from = 0;
241
+ const lower = out.toLowerCase();
242
+ let idx;
243
+ while ((idx = lower.indexOf(pattern, from)) !== -1) {
244
+ result += out.slice(from, idx) + `[${id}]`;
245
+ from = idx + pattern.length;
246
+ }
247
+ out = result + out.slice(from);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ export function loadAllowBinary(file) {
253
+ if (!file) return new Set();
254
+ return new Set(
255
+ fs.readFileSync(file, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"))
256
+ );
257
+ }
258
+
259
+ export function runScan({ root, staged, denylistPath, allowBinaryPath, allowBinary }) {
260
+ const denylist = denylistPath ? loadDenylist(denylistPath) : [];
261
+ const allow = allowBinary ?? loadAllowBinary(allowBinaryPath);
262
+ const entries = staged ? stagedFiles(root).map((rel) => ({ rel })) : treeEntries(root);
263
+ const findings = [];
264
+ for (const entry of entries) {
265
+ const rel = entry.rel;
266
+ const relPosix = rel.split(path.sep).join("/");
267
+ const full = path.join(root, rel);
268
+
269
+ // Symlinks (tree mode): scan the target STRING for leaks, never follow.
270
+ if (entry.symlink) {
271
+ let target = "";
272
+ try {
273
+ target = fs.readlinkSync(full);
274
+ } catch {
275
+ findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: relPosix, line: 0, detail: "symlink unreadable - fail closed" });
276
+ continue;
277
+ }
278
+ findings.push(...scanBytes(relPosix, Buffer.from(target, "utf8"), denylist));
279
+ continue;
280
+ }
281
+
282
+ let buf;
283
+ try {
284
+ buf = staged
285
+ ? execFileSync("git", ["-C", root, "show", `:${rel}`], { maxBuffer: 512 * 1024 * 1024 })
286
+ : fs.readFileSync(full);
287
+ } catch (e) {
288
+ // Fail closed: an unreadable/oversize file blocks the gate instead of
289
+ // being silently skipped (the pre-commit staged mode especially).
290
+ findings.push({ ruleId: "SCAN-UNREADABLE", severity: "P0", file: relPosix, line: 0, detail: `unscannable (${e.code ?? "read error"}) - fail closed` });
291
+ continue;
292
+ }
293
+ if (isBinary(buf)) {
294
+ // Never silently skip: byte-scan for leaks, and classify the binary.
295
+ findings.push(...scanBytes(relPosix, buf, denylist));
296
+ const cls = classifyExtension(relPosix);
297
+ if (cls === "text")
298
+ findings.push({ ruleId: "BIN-TEXT", severity: "P0", file: relPosix, line: 0, detail: "text-expected file is binary (leak-scan blind spot)" });
299
+ else if (cls === "unknown" && !allow.has(relPosix))
300
+ findings.push({ ruleId: "BIN-UNCLASSIFIED", severity: "P0", file: relPosix, line: 0, detail: "unclassified binary; add to --allow-binary if a legitimate asset" });
301
+ continue;
302
+ }
303
+ findings.push(...scanContent(relPosix, buf.toString("utf8"), denylist));
304
+ }
305
+ const safeFindings = findings.map((f) => ({ ...f, file: redactText(f.file, denylist) }));
306
+ const counts = { P0: findings.filter((f) => f.severity === "P0").length, P1: findings.filter((f) => f.severity === "P1").length };
307
+ return { version: 1, root: redactText(path.resolve(root), denylist), mode: staged ? "staged" : "tree", rulesLoaded: { denylist: denylist.length, builtin: Object.keys(RULES).length + 2 }, counts, findings: safeFindings };
308
+ }
309
+
310
+ function formatHuman(report) {
311
+ const lines = [`privacy scan (${report.mode}) - ${report.findings.length} finding(s), P0=${report.counts.P0} P1=${report.counts.P1}`];
312
+ for (const f of report.findings) lines.push(` [${f.severity}] ${f.ruleId} ${f.file}${f.line ? ":" + f.line : ""}`);
313
+ if (!report.findings.length) lines.push(" clean");
314
+ return lines.join("\n");
315
+ }
316
+
317
+ function main() {
318
+ let args;
319
+ try {
320
+ args = parseArgs({
321
+ options: {
322
+ denylist: { type: "string" },
323
+ root: { type: "string", default: "." },
324
+ staged: { type: "boolean", default: false },
325
+ "allow-binary": { type: "string" },
326
+ json: { type: "boolean", default: false },
327
+ out: { type: "string" },
328
+ help: { type: "boolean", default: false },
329
+ },
330
+ allowPositionals: false,
331
+ }).values;
332
+ } catch (err) {
333
+ console.error(String(err.message ?? err));
334
+ console.error(USAGE);
335
+ process.exit(2);
336
+ }
337
+ if (args.help) {
338
+ console.log(USAGE);
339
+ process.exit(0);
340
+ }
341
+ if (!fs.existsSync(args.root) || !fs.statSync(args.root).isDirectory()) {
342
+ console.error(`root is not a directory: ${args.root}`);
343
+ process.exit(2);
344
+ }
345
+ if (args.denylist && !fs.existsSync(args.denylist)) {
346
+ console.error(`denylist file not found`);
347
+ process.exit(2);
348
+ }
349
+ if (args["allow-binary"] && !fs.existsSync(args["allow-binary"])) {
350
+ console.error(`allow-binary file not found`);
351
+ process.exit(2);
352
+ }
353
+ const report = runScan({ root: args.root, staged: args.staged, denylistPath: args.denylist, allowBinaryPath: args["allow-binary"] });
354
+ const output = args.json ? JSON.stringify(report, null, 2) : formatHuman(report);
355
+ console.log(output);
356
+ if (args.out) {
357
+ const outDir = path.dirname(path.resolve(args.out));
358
+ const tmpOut = path.join(outDir, `.tmp-report-${process.pid}`);
359
+ fs.mkdirSync(outDir, { recursive: true });
360
+ fs.writeFileSync(tmpOut, JSON.stringify(report, null, 2) + "\n");
361
+ fs.renameSync(tmpOut, path.resolve(args.out));
362
+ }
363
+ process.exit(report.findings.length ? 1 : 0);
364
+ }
365
+
366
+ // Main-module guard: realpath both sides - Node resolves symlinks (e.g. /tmp
367
+ // on macOS) when building import.meta.url for the entry module.
368
+ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) main();
@@ -0,0 +1,94 @@
1
+ # Deterministic validators
2
+
3
+ **The rule that separates a validator from a test.** A validator inspects an
4
+ artifact that exists in the repository, or one it constructs deterministically
5
+ in a temp directory within the same run and deletes afterwards. It has no
6
+ committed fixtures, no test framework, no phase labels, and no simulated
7
+ product. It must FAIL rather than skip when its input is absent.
8
+
9
+ That last sentence is the whole point. A gate that turns itself off when the
10
+ thing it guards goes missing is worse than no gate, because it reports success
11
+ while checking nothing.
12
+
13
+ This repository ships no automated test suite for the plugin itself. Quality is
14
+ proven here, against real artifacts, and by building a real product with the
15
+ tool. Products built with Superdev do get tests; Superdev does not test a
16
+ simulated copy of itself. `no-tests.mjs` is the guard that stops the suite
17
+ growing back.
18
+
19
+ ## Running
20
+
21
+ ```sh
22
+ npm run validate # the repository in the working directory
23
+ node scripts/validate/validate-all.mjs --root <path>
24
+ node scripts/validate/validate-all.mjs --only migrations,imports
25
+ node scripts/validate/validate-all.mjs --json --out report.json
26
+ ```
27
+
28
+ Exit codes: `0` no findings, `1` findings, `2` usage error.
29
+
30
+ `--out` writes exactly the machine-readable report, atomically, and nothing is
31
+ written without it.
32
+
33
+ ## Interface
34
+
35
+ Every validator is a standalone module exporting `run(root)`:
36
+
37
+ ```js
38
+ { name: "migrations", findings: [{ code, severity, path, message }] }
39
+ ```
40
+
41
+ - `code` is stable and greppable (`MG-007`), so a finding can be referred to.
42
+ - `severity` is `error` or `warning`. Both are findings and both exit non-zero;
43
+ the distinction says how urgent it is, not whether it counts.
44
+ - `path` is repo-relative with forward slashes, optionally `:line` or
45
+ `:line:column`. An absolute machine path never appears in output.
46
+ - `message` says what is wrong and, where it is not obvious, what to do.
47
+
48
+ A validator that throws is reported as a `VA-001` finding against itself. A
49
+ broken validator never reads as a passing run.
50
+
51
+ ## The validators
52
+
53
+ | Module | Checks | Fails when its input is absent |
54
+ |---|---|---|
55
+ | `manifests.mjs` | Claude, Codex and marketplace manifests plus `hooks.json`: valid JSON, required fields, kebab name, no absolute or escaping paths | a manifest is missing |
56
+ | `skills.mjs` | `skills/*/SKILL.md`: frontmatter parses, name matches directory, description present and under 1024 characters, body under 500 lines, no working-directory-dependent commands, every named file resolves | `skills/` is missing or empty |
57
+ | `docs-templates.mjs` | Docs templates and fragments stay neutral: no vendor, latency, cloud region, compliance regime or concrete environment variable | the templates or fragments directory is missing |
58
+ | `migrations.mjs` | Builds a throwaway database in a temp directory, applies the full ordered set forward, runs integrity and foreign key checks, asserts the applied list equals the checked-in files, proves the task mapping constraint and the append-only triggers fire and that a mapped task still passes, then deletes it | no migration files are found |
59
+ | `markdown.mjs` | Generated Markdown under `talks/` carries a generation marker, its body still hashes to the recorded hash, and every relative link resolves | `talks/` is missing or holds no Markdown |
60
+ | `style.mjs` | No em dash character and no emoji in project-owned content | there are no project-owned text files |
61
+ | `privacy.mjs` | No absolute home paths, no secret-shaped strings, no private project identifiers | there are no project-owned text files |
62
+ | `imports.mjs` | Every module under `src/` and `scripts/` imports only node builtins, relative paths, or the one allowlisted runtime dependency, which only `src/db/connect.mjs` may import | neither directory exists |
63
+ | `dependencies.mjs` | `package.json` declares only allowlisted runtime dependencies, pins them exactly, declares the Node floor, and carries no dev toolchain | `package.json` is missing |
64
+ | `no-tests.mjs` | No `*.test.mjs`, no test or fixture tree, no test runner script or framework dependency | never; a missing root is itself the finding |
65
+ | `footprint.mjs` | No file-per-record operational tracking: no file named after a task, subtask, assignment, transition, evidence item, activity event, session, agent, branch, memory entry, sync cursor or conflict, and no directory named after one that has filled with data documents | never; an empty root is itself the finding |
66
+
67
+ ## Private identifiers
68
+
69
+ The denylist of private project identifiers lives outside this repository by
70
+ design and is supplied at run time:
71
+
72
+ ```sh
73
+ SUPERDEV_DENYLIST=<path outside the repo> npm run validate
74
+ ```
75
+
76
+ Entries are never echoed into output. Findings carry a rule id and a location
77
+ only, because printing the matched value would be the leak the scan exists to
78
+ prevent. When the variable points at a file that does not exist, `privacy.mjs`
79
+ reports it rather than passing: a check that silently did not run must not read
80
+ as a check that passed.
81
+
82
+ The screening rules themselves come from `src/model/screening.mjs`, the same
83
+ module the storage boundary uses, so a string the database would refuse cannot
84
+ arrive through a file instead.
85
+
86
+ ## Adding one
87
+
88
+ Write `scripts/validate/<name>.mjs` exporting `name` and `run(root)`, then add
89
+ it to `VALIDATORS` in `validate-all.mjs`. Declaration order is report order.
90
+
91
+ Before adding it, check it against the rule at the top of this file. If it needs
92
+ a fixture, it is a test. If it simulates product behavior, it is a test. If it
93
+ passes quietly when the artifact it inspects is not there, it is worse than a
94
+ test.