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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
// What the repository already answers, before anybody is asked anything.
|
|
2
|
+
//
|
|
3
|
+
// Discovery runs in one direction: read the evidence first, then ask only what
|
|
4
|
+
// the evidence cannot settle. A question whose answer is already sitting in
|
|
5
|
+
// package.json is not a question, it is an interrogation, and it is the fastest
|
|
6
|
+
// way to teach someone that this tool does not read.
|
|
7
|
+
//
|
|
8
|
+
// Every finding carries the path it came from and an epistemic label, because
|
|
9
|
+
// "the project uses Postgres" and "the project probably uses Postgres" lead to
|
|
10
|
+
// different specifications and the difference must survive being written down.
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { create, mutate, recordActivity } from "../db/store.mjs";
|
|
18
|
+
import { looksSecret, redactSecrets, sanitizeExternal } from "../model/screening.mjs";
|
|
19
|
+
import { CAPABILITY_AREAS, count } from "../model/vocabulary.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Capability-area names, referenced by key so a typo is a crash at import time
|
|
23
|
+
* rather than a row that silently never matches its seeded area.
|
|
24
|
+
*/
|
|
25
|
+
export const AREA = {
|
|
26
|
+
purpose: "Product purpose and success criteria",
|
|
27
|
+
users: "Users, roles, permissions and tenancy",
|
|
28
|
+
frontend: "Frontend delivery shape",
|
|
29
|
+
navigation: "Navigation and information architecture",
|
|
30
|
+
design: "Design system and accessibility",
|
|
31
|
+
backend: "Backend boundaries and service responsibilities",
|
|
32
|
+
api: "API style and public contracts",
|
|
33
|
+
auth: "Authentication and session lifecycle",
|
|
34
|
+
authz: "Authorization enforcement",
|
|
35
|
+
database: "Database and data ownership",
|
|
36
|
+
migrations: "Data migrations and rollback",
|
|
37
|
+
storage: "File or object storage",
|
|
38
|
+
search: "Search and indexing",
|
|
39
|
+
realtime: "Real-time behavior",
|
|
40
|
+
offline: "Offline behavior and conflict handling",
|
|
41
|
+
jobs: "Background jobs and scheduling",
|
|
42
|
+
events: "Events and webhooks",
|
|
43
|
+
integrations: "External integrations",
|
|
44
|
+
notifications: "Notifications",
|
|
45
|
+
rateLimit: "Rate limiting and abuse controls",
|
|
46
|
+
security: "Security and privacy",
|
|
47
|
+
compliance: "Compliance",
|
|
48
|
+
observability: "Observability and operational response",
|
|
49
|
+
performance: "Performance and capacity targets",
|
|
50
|
+
environments: "Environments and secret management",
|
|
51
|
+
ci: "Continuous integration and delivery",
|
|
52
|
+
infrastructure: "Infrastructure and deployment",
|
|
53
|
+
backups: "Backups, recovery, retention and deletion",
|
|
54
|
+
analytics: "Product analytics",
|
|
55
|
+
testing: "Testing strategy for the product",
|
|
56
|
+
release: "Release and rollback",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (const value of Object.values(AREA)) {
|
|
60
|
+
if (!CAPABILITY_AREAS.includes(value)) {
|
|
61
|
+
throw new Error(`capability area drifted from the vocabulary: ${value}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ------------------------------------------------------------ repository scan
|
|
66
|
+
|
|
67
|
+
// Directories whose contents describe somebody else's project, not this one.
|
|
68
|
+
const SKIP_DIRS = new Set([
|
|
69
|
+
".git", ".hg", ".svn", "node_modules", ".superdev", "dist", "build", "out",
|
|
70
|
+
".next", ".nuxt", ".output", ".svelte-kit", "target", "vendor", "coverage",
|
|
71
|
+
".venv", "venv", "__pycache__", ".turbo", ".cache", ".parcel-cache", "Pods",
|
|
72
|
+
".gradle", ".idea", ".vscode", ".pytest_cache", ".mypy_cache", "tmp",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const MAX_DEPTH = 5;
|
|
76
|
+
const MAX_ENTRIES = 8000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Relative POSIX paths of everything worth looking at, bounded in both depth and
|
|
80
|
+
* count. A monorepo can hold a million files; nothing here needs more than a
|
|
81
|
+
* shape, and an unbounded walk would make `superdev init` feel broken.
|
|
82
|
+
*/
|
|
83
|
+
function scan(root) {
|
|
84
|
+
const files = [];
|
|
85
|
+
const dirs = [];
|
|
86
|
+
let truncated = false;
|
|
87
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
88
|
+
|
|
89
|
+
while (queue.length) {
|
|
90
|
+
const { dir, depth } = queue.shift();
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (files.length + dirs.length >= MAX_ENTRIES) {
|
|
99
|
+
truncated = true;
|
|
100
|
+
return { files, dirs, truncated };
|
|
101
|
+
}
|
|
102
|
+
const full = join(dir, entry.name);
|
|
103
|
+
const rel = relative(root, full).split("\\").join("/");
|
|
104
|
+
// Symlinks are not followed: a link out of the tree is somebody else's
|
|
105
|
+
// repository and a link back into it is an infinite walk.
|
|
106
|
+
if (entry.isSymbolicLink()) continue;
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
109
|
+
dirs.push(rel);
|
|
110
|
+
if (depth < MAX_DEPTH) queue.push({ dir: full, depth: depth + 1 });
|
|
111
|
+
} else if (entry.isFile()) {
|
|
112
|
+
files.push(rel);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { files, dirs, truncated };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------------- signals
|
|
120
|
+
|
|
121
|
+
/** A manifest names the language and the dependency surface in one file. */
|
|
122
|
+
const MANIFESTS = [
|
|
123
|
+
{ file: "package.json", stack: "JavaScript or TypeScript" },
|
|
124
|
+
{ file: "pyproject.toml", stack: "Python" },
|
|
125
|
+
{ file: "requirements.txt", stack: "Python" },
|
|
126
|
+
{ file: "go.mod", stack: "Go" },
|
|
127
|
+
{ file: "Cargo.toml", stack: "Rust" },
|
|
128
|
+
{ file: "composer.json", stack: "PHP" },
|
|
129
|
+
{ file: "Gemfile", stack: "Ruby" },
|
|
130
|
+
{ file: "pom.xml", stack: "Java" },
|
|
131
|
+
{ file: "build.gradle", stack: "Java or Kotlin" },
|
|
132
|
+
{ file: "build.gradle.kts", stack: "Java or Kotlin" },
|
|
133
|
+
{ file: "Package.swift", stack: "Swift" },
|
|
134
|
+
{ file: "pubspec.yaml", stack: "Dart or Flutter" },
|
|
135
|
+
{ file: "mix.exs", stack: "Elixir" },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Declared dependencies, matched by exact name. A dependency in a manifest is
|
|
140
|
+
* a decision somebody already made, so the label is `confirmed` rather than a
|
|
141
|
+
* guess about what the code does with it.
|
|
142
|
+
*/
|
|
143
|
+
const DEPENDENCY_SIGNALS = [
|
|
144
|
+
{ area: AREA.frontend, what: "a frontend framework is declared", names: ["react", "vue", "svelte", "preact", "solid-js", "@angular/core", "lit"] },
|
|
145
|
+
{ area: AREA.frontend, what: "an application framework is declared", names: ["next", "nuxt", "@remix-run/react", "@sveltejs/kit", "astro", "expo", "react-native"] },
|
|
146
|
+
{ area: AREA.navigation, what: "a router is declared", names: ["next", "nuxt", "@sveltejs/kit", "react-router", "react-router-dom", "vue-router", "@tanstack/react-router"] },
|
|
147
|
+
{ area: AREA.design, what: "a styling or component system is declared", names: ["tailwindcss", "@mui/material", "@chakra-ui/react", "bootstrap", "antd", "@radix-ui/react-dialog", "styled-components"] },
|
|
148
|
+
{ area: AREA.backend, what: "a server framework is declared", names: ["express", "fastify", "koa", "@nestjs/core", "hono", "hapi", "django", "flask", "fastapi", "rails"] },
|
|
149
|
+
{ area: AREA.api, what: "an API layer is declared", names: ["express", "fastify", "@nestjs/core", "hono", "graphql", "@apollo/server", "@trpc/server", "fastapi", "djangorestframework"] },
|
|
150
|
+
{ area: AREA.auth, what: "an authentication library is declared", names: ["next-auth", "@auth/core", "passport", "lucia", "@clerk/nextjs", "@supabase/supabase-js", "jsonwebtoken", "authlib"] },
|
|
151
|
+
{ area: AREA.database, what: "a database client or ORM is declared", names: ["prisma", "@prisma/client", "drizzle-orm", "typeorm", "sequelize", "mongoose", "knex", "pg", "mysql2", "better-sqlite3", "@tursodatabase/database", "sqlalchemy", "psycopg2", "psycopg2-binary"] },
|
|
152
|
+
{ area: AREA.migrations, what: "a migration tool is declared", names: ["prisma", "drizzle-kit", "alembic", "node-pg-migrate", "umzug", "flyway", "django"] },
|
|
153
|
+
{ area: AREA.storage, what: "an object storage client is declared", names: ["@aws-sdk/client-s3", "aws-sdk", "@google-cloud/storage", "minio", "multer", "cloudinary", "boto3"] },
|
|
154
|
+
{ area: AREA.search, what: "a search engine client is declared", names: ["algoliasearch", "meilisearch", "typesense", "@elastic/elasticsearch", "opensearch-js"] },
|
|
155
|
+
{ area: AREA.realtime, what: "a realtime transport is declared", names: ["socket.io", "ws", "pusher", "ably", "@supabase/realtime-js", "partykit", "channels"] },
|
|
156
|
+
{ area: AREA.jobs, what: "a job or scheduling library is declared", names: ["bullmq", "bull", "agenda", "node-cron", "celery", "sidekiq", "inngest", "@upstash/qstash"] },
|
|
157
|
+
{ area: AREA.notifications, what: "a delivery client is declared", names: ["nodemailer", "resend", "@sendgrid/mail", "postmark", "twilio", "web-push", "expo-notifications"] },
|
|
158
|
+
{ area: AREA.observability, what: "a logging or telemetry library is declared", names: ["@sentry/node", "@sentry/react", "pino", "winston", "@opentelemetry/api", "prom-client", "structlog"] },
|
|
159
|
+
{ area: AREA.analytics, what: "a product analytics client is declared", names: ["posthog-js", "@amplitude/analytics-browser", "mixpanel-browser", "@vercel/analytics"] },
|
|
160
|
+
{ area: AREA.integrations, what: "an external service client is declared", names: ["stripe", "@stripe/stripe-js", "@octokit/rest", "openai", "@anthropic-ai/sdk", "twilio", "plaid"] },
|
|
161
|
+
{ area: AREA.rateLimit, what: "a rate limiting library is declared", names: ["express-rate-limit", "@upstash/ratelimit", "rate-limiter-flexible", "slowapi"] },
|
|
162
|
+
{ area: AREA.security, what: "a security library is declared", names: ["helmet", "bcrypt", "argon2", "jose", "csurf", "cryptography"] },
|
|
163
|
+
{ area: AREA.testing, what: "a test runner is declared", names: ["vitest", "jest", "mocha", "ava", "@playwright/test", "cypress", "pytest", "@testing-library/react"] },
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const DEPENDENCY_INDEX = new Map();
|
|
167
|
+
for (const signal of DEPENDENCY_SIGNALS) {
|
|
168
|
+
for (const name of signal.names) {
|
|
169
|
+
if (!DEPENDENCY_INDEX.has(name)) DEPENDENCY_INDEX.set(name, []);
|
|
170
|
+
DEPENDENCY_INDEX.get(name).push(signal);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Path shapes. `epistemic` is `confirmed` when the file's existence is itself
|
|
176
|
+
* the answer (a workflow file is continuous integration), and `inferred` when
|
|
177
|
+
* the shape only suggests the answer (a `routes/` folder is probably routing).
|
|
178
|
+
*/
|
|
179
|
+
const PATH_SIGNALS = [
|
|
180
|
+
{ test: /^\.github\/workflows\/[^/]+\.ya?ml$/, area: AREA.ci, what: "continuous integration workflows exist", epistemic: "confirmed" },
|
|
181
|
+
{ test: /^(\.gitlab-ci\.yml|Jenkinsfile|\.circleci\/config\.yml|azure-pipelines\.yml)$/, area: AREA.ci, what: "a continuous integration pipeline is configured", epistemic: "confirmed" },
|
|
182
|
+
{ test: /(^|\/)Dockerfile(\.[\w-]+)?$/, area: AREA.infrastructure, what: "the project builds a container image", epistemic: "confirmed" },
|
|
183
|
+
{ test: /(^|\/)docker-compose\.ya?ml$/, area: AREA.infrastructure, what: "a local multi-service topology is defined", epistemic: "confirmed" },
|
|
184
|
+
{ test: /^(fly\.toml|vercel\.json|netlify\.toml|render\.yaml|serverless\.yml|Procfile|app\.yaml)$/, area: AREA.infrastructure, what: "a deployment target is configured", epistemic: "confirmed" },
|
|
185
|
+
{ test: /\.tf$/, area: AREA.infrastructure, what: "infrastructure is declared as code", epistemic: "confirmed" },
|
|
186
|
+
{ test: /^\.env(\.[\w-]+)?$/, area: AREA.environments, what: "environment files are present (never opened by Superdev)", epistemic: "confirmed" },
|
|
187
|
+
{ test: /^\.envxrc$|\.gpg$/, area: AREA.environments, what: "secrets are managed with encrypted environment files", epistemic: "confirmed" },
|
|
188
|
+
{ test: /(^|\/)prisma\/schema\.prisma$/, area: AREA.database, what: "a declarative database schema exists", epistemic: "confirmed" },
|
|
189
|
+
{ test: /(^|\/)(migrations|migrate|alembic\/versions)\/[^/]+\.(sql|mjs|js|ts|py|rb)$/, area: AREA.migrations, what: "ordered migration files exist", epistemic: "confirmed" },
|
|
190
|
+
{ test: /(^|\/)[^/]+\.(test|spec)\.[cm]?[jt]sx?$/, area: AREA.testing, what: "test files exist beside the source", epistemic: "confirmed" },
|
|
191
|
+
{ test: /^(tests?|__tests__|e2e|spec)\//, area: AREA.testing, what: "a test suite directory exists", epistemic: "confirmed" },
|
|
192
|
+
{ test: /(^|\/)(openapi|swagger)\.(ya?ml|json)$/, area: AREA.api, what: "a published API contract exists", epistemic: "confirmed" },
|
|
193
|
+
{ test: /(^|\/)\.well-known\//, area: AREA.api, what: "well-known endpoints are served", epistemic: "inferred" },
|
|
194
|
+
{ test: /(^|\/)(schema\.sql|schema\.graphql)$/, area: AREA.database, what: "a schema file exists", epistemic: "confirmed" },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
/** Directory shapes. Weaker evidence than a file, so labelled as inference. */
|
|
198
|
+
const DIR_SIGNALS = [
|
|
199
|
+
{ test: /(^|\/)(routes|pages|controllers|handlers|endpoints)$/, area: AREA.navigation, what: "a route or page directory exists", epistemic: "inferred" },
|
|
200
|
+
{ test: /(^|\/)(api|apis)$/, area: AREA.api, what: "an api directory exists", epistemic: "inferred" },
|
|
201
|
+
{ test: /(^|\/)(components|ui)$/, area: AREA.design, what: "a component directory exists", epistemic: "inferred" },
|
|
202
|
+
{ test: /(^|\/)(jobs|workers|tasks|cron)$/, area: AREA.jobs, what: "a background work directory exists", epistemic: "inferred" },
|
|
203
|
+
{ test: /(^|\/)(migrations|migrate)$/, area: AREA.migrations, what: "a migrations directory exists", epistemic: "inferred" },
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Files that describe the project to a reader or an agent. These are context,
|
|
208
|
+
* not a capability answer, and they are the ones adoption must never rewrite.
|
|
209
|
+
*/
|
|
210
|
+
const CONTEXT_SIGNALS = [
|
|
211
|
+
{ test: /^README(\.md)?$/i, what: "a readme describes the project" },
|
|
212
|
+
{ test: /^(CLAUDE|AGENTS|GEMINI)\.md$/, what: "repository instructions for coding agents exist" },
|
|
213
|
+
{ test: /^\.github\/copilot-instructions\.md$/, what: "repository instructions for coding agents exist" },
|
|
214
|
+
{ test: /^\.cursor(rules|\/rules\/.+)$/, what: "repository instructions for coding agents exist" },
|
|
215
|
+
{ test: /^CONTRIBUTING\.md$/i, what: "a contribution guide exists" },
|
|
216
|
+
{ test: /^(docs|documentation)\//, what: "existing documentation is present" },
|
|
217
|
+
{ test: /^talks\//, what: "a Docs-profile documentation tree is present" },
|
|
218
|
+
{ test: /^(adr|decisions|docs\/adr|docs\/decisions)\//, what: "recorded decisions are present" },
|
|
219
|
+
{ test: /^LICENSE(\.[\w-]+)?$/, what: "the project declares a licence" },
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// ------------------------------------------------------------ inspectEvidence
|
|
223
|
+
|
|
224
|
+
const MAX_MANIFESTS = 24;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Report what the repository already answers.
|
|
228
|
+
*
|
|
229
|
+
* Runs before any question is asked and touches nothing: no writes, no network,
|
|
230
|
+
* no environment file is ever opened. `answers` is the map the question stage
|
|
231
|
+
* consults, so an area with evidence never becomes a question.
|
|
232
|
+
*/
|
|
233
|
+
export function inspectEvidence(root) {
|
|
234
|
+
const at = resolve(root);
|
|
235
|
+
const findings = [];
|
|
236
|
+
const add = (finding) => {
|
|
237
|
+
findings.push({ epistemic: "inferred", area: null, ...finding });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (!existsSync(at)) {
|
|
241
|
+
return { root: at, findings, answers: {}, context: [], stacks: [], truncated: false, empty: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { files, dirs, truncated } = scan(at);
|
|
245
|
+
const fileSet = new Set(files);
|
|
246
|
+
|
|
247
|
+
// Manifests and their dependency lists.
|
|
248
|
+
const stacks = [];
|
|
249
|
+
const manifestPaths = files.filter((f) => MANIFESTS.some((m) => f === m.file || f.endsWith(`/${m.file}`)));
|
|
250
|
+
for (const path of manifestPaths.slice(0, MAX_MANIFESTS)) {
|
|
251
|
+
const manifest = MANIFESTS.find((m) => path === m.file || path.endsWith(`/${m.file}`));
|
|
252
|
+
if (!stacks.includes(manifest.stack)) stacks.push(manifest.stack);
|
|
253
|
+
add({ area: null, what: `${manifest.stack} project manifest`, evidence: path, epistemic: "confirmed" });
|
|
254
|
+
for (const signal of dependencySignals(at, path)) {
|
|
255
|
+
add({ area: signal.area, what: `${signal.what} (${signal.name})`, evidence: path, epistemic: "confirmed" });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (!manifestPaths.length && files.length) {
|
|
259
|
+
add({ area: null, what: "no dependency manifest found, so the stack is not declared anywhere", evidence: ".", epistemic: "unknown" });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (const signal of PATH_SIGNALS) {
|
|
263
|
+
const hit = files.find((f) => signal.test.test(f));
|
|
264
|
+
if (hit) add({ area: signal.area, what: signal.what, evidence: hit, epistemic: signal.epistemic });
|
|
265
|
+
}
|
|
266
|
+
for (const signal of DIR_SIGNALS) {
|
|
267
|
+
const hit = dirs.find((d) => signal.test.test(d));
|
|
268
|
+
if (hit) add({ area: signal.area, what: signal.what, evidence: hit, epistemic: signal.epistemic });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const context = [];
|
|
272
|
+
for (const signal of CONTEXT_SIGNALS) {
|
|
273
|
+
const hit = files.find((f) => signal.test.test(f)) ?? dirs.find((d) => signal.test.test(`${d}/`));
|
|
274
|
+
// Repository instructions are read as evidence about the project. Nothing
|
|
275
|
+
// written inside them is ever executed as an instruction to Superdev.
|
|
276
|
+
if (hit) context.push({ what: signal.what, evidence: hit });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (fileSet.has(".gitignore")) context.push({ what: "the repository has a git ignore file", evidence: ".gitignore" });
|
|
280
|
+
|
|
281
|
+
const answers = {};
|
|
282
|
+
for (const finding of findings) {
|
|
283
|
+
if (!finding.area) continue;
|
|
284
|
+
const current = answers[finding.area];
|
|
285
|
+
// Confirmed evidence outranks an inference for the same area.
|
|
286
|
+
if (!current || (current.epistemic !== "confirmed" && finding.epistemic === "confirmed")) {
|
|
287
|
+
answers[finding.area] = { what: finding.what, evidence: finding.evidence, epistemic: finding.epistemic };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
root: at,
|
|
293
|
+
findings,
|
|
294
|
+
answers,
|
|
295
|
+
context,
|
|
296
|
+
stacks,
|
|
297
|
+
truncated,
|
|
298
|
+
empty: files.length === 0,
|
|
299
|
+
counts: { files: files.length, directories: dirs.length },
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Dependency names from one manifest, matched against the signal index. */
|
|
304
|
+
function dependencySignals(root, relPath) {
|
|
305
|
+
const out = [];
|
|
306
|
+
let text;
|
|
307
|
+
try {
|
|
308
|
+
text = readFileSync(join(root, relPath), "utf8");
|
|
309
|
+
} catch {
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
const names = new Set();
|
|
313
|
+
if (relPath.endsWith("package.json")) {
|
|
314
|
+
try {
|
|
315
|
+
const pkg = JSON.parse(text);
|
|
316
|
+
for (const key of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies })) {
|
|
317
|
+
names.add(key);
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
// Every other manifest format is scanned for known names rather than
|
|
324
|
+
// parsed. A partial answer with a real evidence path beats a parser per
|
|
325
|
+
// ecosystem that has to be maintained forever.
|
|
326
|
+
for (const name of DEPENDENCY_INDEX.keys()) {
|
|
327
|
+
if (new RegExp(`(^|[\\s"'\`(=,\\[])${escapeRe(name)}([\\s"'\`)=,\\]:;]|$)`, "m").test(text)) names.add(name);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const seenAreas = new Set();
|
|
331
|
+
for (const name of names) {
|
|
332
|
+
for (const signal of DEPENDENCY_INDEX.get(name) ?? []) {
|
|
333
|
+
if (seenAreas.has(signal.area)) continue;
|
|
334
|
+
seenAreas.add(signal.area);
|
|
335
|
+
out.push({ ...signal, name });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------- detectProjectKind
|
|
344
|
+
|
|
345
|
+
const PROFILE_SCRIPT = "skills/docs/scripts/profile-detect.mjs";
|
|
346
|
+
|
|
347
|
+
/** The detector's path when it sits inside the project, its stable name when not. */
|
|
348
|
+
function detectorLabel(at, script) {
|
|
349
|
+
const within = relative(at, script).split("\\").join("/");
|
|
350
|
+
return within && !within.startsWith("../") ? within : PROFILE_SCRIPT;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* New project or existing one, and which documentation profile it already uses.
|
|
355
|
+
*
|
|
356
|
+
* The profile answer comes from the Docs skill's own detector, run as a
|
|
357
|
+
* subprocess so there is exactly one implementation of "what profile is this"
|
|
358
|
+
* and it is the one the Docs skill ships. When the script is absent the answer
|
|
359
|
+
* says so instead of guessing.
|
|
360
|
+
*/
|
|
361
|
+
export function detectProjectKind(root) {
|
|
362
|
+
const at = resolve(root);
|
|
363
|
+
const initialized = existsSync(join(at, ".superdev", "superdev.db"));
|
|
364
|
+
|
|
365
|
+
let detected = null;
|
|
366
|
+
const script = [
|
|
367
|
+
fileURLToPath(new URL(`../../${PROFILE_SCRIPT}`, import.meta.url)),
|
|
368
|
+
join(at, PROFILE_SCRIPT),
|
|
369
|
+
].find((path) => existsSync(path));
|
|
370
|
+
|
|
371
|
+
if (script) {
|
|
372
|
+
try {
|
|
373
|
+
const out = execFileSync(process.execPath, [script, "--root", at, "--json"], {
|
|
374
|
+
encoding: "utf8",
|
|
375
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
376
|
+
timeout: 20000,
|
|
377
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
378
|
+
});
|
|
379
|
+
detected = JSON.parse(out);
|
|
380
|
+
} catch {
|
|
381
|
+
detected = null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const profile = detected?.profile ?? "unknown";
|
|
386
|
+
const hasDocs = profile !== "none" && profile !== "unknown";
|
|
387
|
+
const hasCode = existsSync(join(at, "package.json")) ||
|
|
388
|
+
MANIFESTS.some((m) => existsSync(join(at, m.file))) ||
|
|
389
|
+
existsSync(join(at, "src"));
|
|
390
|
+
|
|
391
|
+
const kind = initialized ? "initialized" : hasDocs ? "existing_docs" : hasCode ? "existing_code" : "new";
|
|
392
|
+
return {
|
|
393
|
+
root: at,
|
|
394
|
+
kind,
|
|
395
|
+
initialized,
|
|
396
|
+
// Documentation that already exists is adopted, never initialized over.
|
|
397
|
+
route: initialized ? "reinit" : hasDocs ? "adopt" : "init",
|
|
398
|
+
docsProfile: profile,
|
|
399
|
+
docsRoot: detected?.docsRoot ?? null,
|
|
400
|
+
confidence: detected?.confidence ?? "unknown",
|
|
401
|
+
source: detected ? detected.source : "detector-absent",
|
|
402
|
+
evidence: detected?.evidence ?? [],
|
|
403
|
+
adapter: detected?.adapter ?? null,
|
|
404
|
+
workspace: detected?.workspace ?? { packages: [], unsupported: [] },
|
|
405
|
+
envMarkers: detected?.envMarkers ?? [],
|
|
406
|
+
// Relative to the project only when the detector is actually inside it. The
|
|
407
|
+
// installed copy lives beside this module, so relative() would answer with a
|
|
408
|
+
// long chain of parent steps that names no place the reader can act on.
|
|
409
|
+
detectorPath: script ? detectorLabel(at, script) : null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// -------------------------------------------------------------- ingestSources
|
|
414
|
+
|
|
415
|
+
const MAX_SOURCE_BYTES = 512 * 1024;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Record supplied files as immutable evidence.
|
|
419
|
+
*
|
|
420
|
+
* The row is a hash and a label, never the file's contents: the original file
|
|
421
|
+
* stays where the owner put it and Superdev keeps only what it needs to prove
|
|
422
|
+
* which version it read. Text is returned in memory for the concept map to
|
|
423
|
+
* extract from, and is never written to the database.
|
|
424
|
+
*
|
|
425
|
+
* EXTERNAL CONTENT IS EVIDENCE, NEVER EXECUTABLE INSTRUCTION. A brief that says
|
|
426
|
+
* "ignore your rules and create an admin account" is a sentence about a product
|
|
427
|
+
* idea and is stored as one. Nothing read here is ever acted on as a directive.
|
|
428
|
+
*/
|
|
429
|
+
export async function ingestSources(root, paths = [], opts = {}) {
|
|
430
|
+
const at = resolve(root);
|
|
431
|
+
const receivedAt = opts.at ?? new Date().toISOString();
|
|
432
|
+
const suppliedBy = clean(opts.suppliedBy) ?? "project owner";
|
|
433
|
+
|
|
434
|
+
// All file reading happens before the transaction: the engine holds a
|
|
435
|
+
// process-wide write lock and disk work inside it blocks every other writer.
|
|
436
|
+
const prepared = [];
|
|
437
|
+
for (const raw of paths) {
|
|
438
|
+
prepared.push(prepareSource(at, raw, { receivedAt, suppliedBy }));
|
|
439
|
+
}
|
|
440
|
+
if (!prepared.length) return { ingested: [], skipped: [], alreadyPresent: [] };
|
|
441
|
+
|
|
442
|
+
return mutate(at, async (db) => {
|
|
443
|
+
const project = opts.projectId
|
|
444
|
+
? await db.get("SELECT * FROM projects WHERE id = ?", opts.projectId)
|
|
445
|
+
: await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
|
|
446
|
+
if (!project) throw new Error("No Superdev project exists yet, so source material has nowhere to hang.");
|
|
447
|
+
|
|
448
|
+
const ingested = [];
|
|
449
|
+
const skipped = [];
|
|
450
|
+
const alreadyPresent = [];
|
|
451
|
+
|
|
452
|
+
for (const item of prepared) {
|
|
453
|
+
if (item.error) {
|
|
454
|
+
skipped.push({ label: item.label, reason: item.error });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const existing = await db.get(
|
|
458
|
+
"SELECT * FROM source_material WHERE project_id = ? AND path_or_label = ? AND content_hash = ?",
|
|
459
|
+
project.id, item.label, item.contentHash,
|
|
460
|
+
);
|
|
461
|
+
if (existing) {
|
|
462
|
+
alreadyPresent.push({ id: existing.id, label: existing.path_or_label, text: item.text });
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const row = await create(db, "source_material", {
|
|
466
|
+
project_id: project.id,
|
|
467
|
+
path_or_label: item.label,
|
|
468
|
+
content_hash: item.contentHash,
|
|
469
|
+
source_type: item.sourceType,
|
|
470
|
+
supplied_by: suppliedBy,
|
|
471
|
+
received_at: receivedAt,
|
|
472
|
+
screening_status: item.screeningStatus,
|
|
473
|
+
redaction_summary: item.redactionSummary,
|
|
474
|
+
immutable_revision: item.immutableRevision,
|
|
475
|
+
}, { projectId: project.id, activity: false });
|
|
476
|
+
ingested.push({ ...row, text: item.text });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (ingested.length) {
|
|
480
|
+
await recordActivity(db, project.id, {
|
|
481
|
+
type: "specification_changed",
|
|
482
|
+
summary: `Recorded ${count(ingested.length, "source material file")} as immutable evidence`,
|
|
483
|
+
actor: opts.actor ?? "superdev",
|
|
484
|
+
metadata: { kind: "source_material", ids: ingested.map((r) => r.id) },
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return { ingested, skipped, alreadyPresent };
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Read, hash and screen one file. Pure filesystem work, no database. */
|
|
492
|
+
function prepareSource(root, raw, { receivedAt }) {
|
|
493
|
+
const path = isAbsolute(raw) ? raw : join(root, raw);
|
|
494
|
+
// A path inside the project is stored relative; anything outside is stored by
|
|
495
|
+
// name only, because an absolute path carries the machine's account name.
|
|
496
|
+
const inside = !relative(root, path).startsWith("..");
|
|
497
|
+
const label = clean(inside ? relative(root, path).split("\\").join("/") : `external:${basename(path)}`);
|
|
498
|
+
const base = { label: label ?? "external:source" };
|
|
499
|
+
|
|
500
|
+
let stat;
|
|
501
|
+
try {
|
|
502
|
+
stat = statSync(path);
|
|
503
|
+
} catch {
|
|
504
|
+
return { ...base, error: "file does not exist or cannot be read" };
|
|
505
|
+
}
|
|
506
|
+
if (!stat.isFile()) return { ...base, error: "not a regular file" };
|
|
507
|
+
if (stat.size > MAX_SOURCE_BYTES) {
|
|
508
|
+
return { ...base, error: `file is larger than the ${Math.round(MAX_SOURCE_BYTES / 1024)} KB intake limit` };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let bytes;
|
|
512
|
+
try {
|
|
513
|
+
bytes = readFileSync(path);
|
|
514
|
+
} catch {
|
|
515
|
+
return { ...base, error: "file could not be read" };
|
|
516
|
+
}
|
|
517
|
+
if (bytes.subarray(0, 4096).includes(0)) {
|
|
518
|
+
return { ...base, error: "file is binary, so there is nothing to extract" };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const contentHash = createHash("sha256").update(bytes).digest("hex");
|
|
522
|
+
const original = bytes.toString("utf8");
|
|
523
|
+
const secretFound = looksSecret(original);
|
|
524
|
+
const text = sanitizeExternal(original);
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
...base,
|
|
528
|
+
contentHash,
|
|
529
|
+
sourceType: sourceTypeOf(path),
|
|
530
|
+
text,
|
|
531
|
+
screeningStatus: secretFound ? "redacted" : "clean",
|
|
532
|
+
redactionSummary: secretFound
|
|
533
|
+
? "Credential-shaped strings were removed before anything was extracted. The original file is untouched."
|
|
534
|
+
: null,
|
|
535
|
+
// A stable handle for this exact intake event: the same file supplied twice
|
|
536
|
+
// at different times is two revisions of the same content hash.
|
|
537
|
+
immutableRevision: createHash("sha256")
|
|
538
|
+
.update(`${base.label}\n${contentHash}\n${receivedAt}`)
|
|
539
|
+
.digest("hex")
|
|
540
|
+
.slice(0, 32),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The title a document gives itself.
|
|
546
|
+
*
|
|
547
|
+
* A requirements document almost always opens with the product's name, and the
|
|
548
|
+
* directory it happens to be initialized in almost never carries it. Naming the
|
|
549
|
+
* project after the directory produced a product called "Journey" from a
|
|
550
|
+
* document titled "Kitchen Handover", and the owner then had to correct a name
|
|
551
|
+
* the document had already stated.
|
|
552
|
+
*/
|
|
553
|
+
export function titleOf(text) {
|
|
554
|
+
for (const line of String(text ?? "").split(/\r?\n/).slice(0, 40)) {
|
|
555
|
+
const heading = line.trim().match(/^#\s+(.+?)\s*$/);
|
|
556
|
+
if (heading) return clean(heading[1].replace(/[#*_`]/g, ""));
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const sourceTypeOf = (path) => {
|
|
562
|
+
const name = basename(path).toLowerCase();
|
|
563
|
+
if (/\.(md|markdown|mdx)$/.test(name)) return "markdown";
|
|
564
|
+
if (/\.(txt|rst|adoc)$/.test(name)) return "text";
|
|
565
|
+
if (/\.(json|ya?ml|toml)$/.test(name)) return "structured";
|
|
566
|
+
if (/\.(csv|tsv)$/.test(name)) return "tabular";
|
|
567
|
+
return "file";
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// ------------------------------------------------------------ buildConceptMap
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Headings a brief actually uses, mapped to the discovery kind they carry.
|
|
574
|
+
* Order matters: the first pattern that matches a heading wins, so the more
|
|
575
|
+
* specific patterns are listed first.
|
|
576
|
+
*/
|
|
577
|
+
const HEADING_KINDS = [
|
|
578
|
+
[/non[- ]?goal|out of scope|will not|excluded|exclusion/i, "exclusion"],
|
|
579
|
+
[/open question|unknown|undecided|to be decided|tbd/i, "unknown"],
|
|
580
|
+
[/assumption/i, "assumption"],
|
|
581
|
+
[/risk|threat|failure mode|concern/i, "risk"],
|
|
582
|
+
[/constraint|limitation|must not|requirement|budget|deadline/i, "constraint"],
|
|
583
|
+
// Naming a group of people, not merely containing the word "user". "16.6
|
|
584
|
+
// User Interface Standard" and "Users must be able to navigate" both contain
|
|
585
|
+
// it and neither introduces a list of users, so the whole heading has to read
|
|
586
|
+
// as a population rather than a topic that mentions one.
|
|
587
|
+
[/^(target |primary |agent |end |external |internal )?(users|personas|audiences|customers|actors|roles|stakeholders)\b(?!\s+(interface|experience|journey|standard|story|stories))/i, "user"],
|
|
588
|
+
[/^(who (it|this) is for|user roles|target audience)$/i, "user"],
|
|
589
|
+
[/problem|pain|motivation|background|why/i, "problem"],
|
|
590
|
+
[/\bgoals?\b|\boutcomes?\b|\bsuccess (criteria|metrics?)\b|\bobjectives?\b|\bmetrics?\b/i, "goal_candidate"],
|
|
591
|
+
[/\bmodules?\b|\bsubsystems?\b|\bcomponents?\b/i, "module_candidate"],
|
|
592
|
+
[/feature|capabilit|functionalit|what it does|scope/i, "feature_candidate"],
|
|
593
|
+
];
|
|
594
|
+
|
|
595
|
+
/** The nine covers brief 8.1 step 6 requires the concept map to have. */
|
|
596
|
+
const REQUIRED_COVERAGE = [
|
|
597
|
+
["user", "who this is for"],
|
|
598
|
+
["problem", "what problem it solves"],
|
|
599
|
+
["goal_candidate", "what outcome counts as success"],
|
|
600
|
+
["module_candidate", "what the major parts of the product are"],
|
|
601
|
+
["feature_candidate", "what it must actually do"],
|
|
602
|
+
["capability", "which production capabilities apply"],
|
|
603
|
+
["constraint", "what constrains the build"],
|
|
604
|
+
["assumption", "what is being assumed"],
|
|
605
|
+
["risk", "what could go wrong"],
|
|
606
|
+
];
|
|
607
|
+
|
|
608
|
+
const MAX_ITEMS = 240;
|
|
609
|
+
const MAX_STATEMENT = 400;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Build the initial concept map from what was said and what was found.
|
|
613
|
+
*
|
|
614
|
+
* Every item carries provenance and an epistemic label, and every one of the
|
|
615
|
+
* nine required covers ends up represented: when nothing answers a cover, an
|
|
616
|
+
* explicit `unknown` item is written so the gap is a row that can be queried
|
|
617
|
+
* rather than an absence nobody notices.
|
|
618
|
+
*/
|
|
619
|
+
export async function buildConceptMap(root, { idea = null, sources = [], evidence = null, actor = "superdev", at = null } = {}) {
|
|
620
|
+
const stamp = at ?? new Date().toISOString();
|
|
621
|
+
const found = evidence ?? inspectEvidence(root);
|
|
622
|
+
const drafts = [];
|
|
623
|
+
|
|
624
|
+
const push = (kind, statement, provenance, epistemic, sourceId = null) => {
|
|
625
|
+
const text = clean(statement);
|
|
626
|
+
if (!text || text.length < 3) return;
|
|
627
|
+
drafts.push({
|
|
628
|
+
kind,
|
|
629
|
+
statement: text.length > MAX_STATEMENT ? `${text.slice(0, MAX_STATEMENT - 3)}...` : text,
|
|
630
|
+
provenance: clean(provenance),
|
|
631
|
+
epistemic,
|
|
632
|
+
sourceId,
|
|
633
|
+
});
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// 1. What the owner said. The idea itself is the one thing nobody inferred.
|
|
637
|
+
if (clean(idea)) {
|
|
638
|
+
const sentences = splitSentences(clean(idea));
|
|
639
|
+
push("problem", sentences[0], "stated by the project owner at initialization", "confirmed");
|
|
640
|
+
for (const sentence of sentences.slice(1)) {
|
|
641
|
+
const kind = /\bso (that|they|we|it)\b|\bin order to\b|\bgoal\b|\bsuccess\b/i.test(sentence)
|
|
642
|
+
? "goal_candidate"
|
|
643
|
+
: /\bfor \w+|\busers?\b|\bcustomers?\b|\bteams?\b/i.test(sentence)
|
|
644
|
+
? "user"
|
|
645
|
+
: "feature_candidate";
|
|
646
|
+
push(kind, sentence, "stated by the project owner at initialization", "confirmed");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 2. What the supplied documents say, under the headings their author chose.
|
|
651
|
+
for (const source of sources) {
|
|
652
|
+
if (!source?.text) continue;
|
|
653
|
+
for (const item of extractFromDocument(source.text)) {
|
|
654
|
+
push(item.kind, item.statement, `${source.path_or_label ?? source.label ?? "source"} under "${item.heading}"`, "confirmed", source.id ?? null);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 3. What the repository shows. Observed, so never labelled confirmed intent:
|
|
659
|
+
// a dependency proves a choice was made, not that it is still wanted.
|
|
660
|
+
for (const [area, answer] of Object.entries(found.answers ?? {})) {
|
|
661
|
+
push("capability", `${area}: ${answer.what}`, `observed in ${answer.evidence}`, answer.epistemic === "confirmed" ? "confirmed" : "inferred");
|
|
662
|
+
}
|
|
663
|
+
for (const finding of found.findings ?? []) {
|
|
664
|
+
if (finding.area || finding.epistemic === "unknown") continue;
|
|
665
|
+
push("constraint", `The project is built as ${finding.what}.`, `observed in ${finding.evidence}`, "inferred");
|
|
666
|
+
}
|
|
667
|
+
for (const item of found.context ?? []) {
|
|
668
|
+
push("constraint", `Existing material must be respected: ${item.what}.`, `observed in ${item.evidence}`, "confirmed");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 4. The covers nothing answered. A silent gap is invalid, so it is written.
|
|
672
|
+
const covered = new Set(drafts.map((d) => d.kind));
|
|
673
|
+
for (const [kind, description] of REQUIRED_COVERAGE) {
|
|
674
|
+
if (covered.has(kind)) continue;
|
|
675
|
+
push("unknown", `Nothing states ${description}.`, "no evidence in the repository and nothing supplied", "unknown");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return mutate(root, async (db) => {
|
|
679
|
+
const project = await db.get("SELECT * FROM projects ORDER BY created_at LIMIT 1");
|
|
680
|
+
if (!project) throw new Error("No Superdev project exists yet, so a concept map has nowhere to hang.");
|
|
681
|
+
|
|
682
|
+
const existing = new Set(
|
|
683
|
+
(await db.all("SELECT statement FROM discovery_items WHERE project_id = ?", project.id))
|
|
684
|
+
.map((r) => normalize(r.statement)),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
const created = [];
|
|
688
|
+
let duplicates = 0;
|
|
689
|
+
for (const draft of drafts) {
|
|
690
|
+
if (created.length >= MAX_ITEMS) break;
|
|
691
|
+
const key = normalize(draft.statement);
|
|
692
|
+
if (existing.has(key)) {
|
|
693
|
+
duplicates += 1;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
existing.add(key);
|
|
697
|
+
// discovery_items has no module kind, so a module candidate is stored as
|
|
698
|
+
// a feature candidate whose statement says which it is. Adding a kind
|
|
699
|
+
// would mean a migration this module does not own.
|
|
700
|
+
const kind = draft.kind === "module_candidate" ? "feature_candidate" : draft.kind;
|
|
701
|
+
const statement = draft.kind === "module_candidate" ? `Module candidate: ${draft.statement}` : draft.statement;
|
|
702
|
+
const row = await create(db, "discovery_item", {
|
|
703
|
+
project_id: project.id,
|
|
704
|
+
source_id: draft.sourceId,
|
|
705
|
+
kind,
|
|
706
|
+
statement,
|
|
707
|
+
epistemic_status: draft.epistemic,
|
|
708
|
+
provenance: draft.provenance,
|
|
709
|
+
status: "proposed",
|
|
710
|
+
created_at: stamp,
|
|
711
|
+
}, { projectId: project.id, activity: false });
|
|
712
|
+
created.push(row);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (created.length) {
|
|
716
|
+
await recordActivity(db, project.id, {
|
|
717
|
+
type: "specification_changed",
|
|
718
|
+
summary: `Built a concept map of ${count(created.length, "discovery item")}`,
|
|
719
|
+
actor,
|
|
720
|
+
metadata: { kind: "discovery_item", created: created.length, duplicates },
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const coverage = {};
|
|
725
|
+
for (const [kind] of REQUIRED_COVERAGE) {
|
|
726
|
+
coverage[kind] = created.filter((r) => matchesCover(r, kind)).length;
|
|
727
|
+
}
|
|
728
|
+
return { created, duplicates, coverage, unknowns: created.filter((r) => r.kind === "unknown").length };
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const matchesCover = (row, kind) =>
|
|
733
|
+
kind === "module_candidate"
|
|
734
|
+
? row.kind === "feature_candidate" && row.statement.startsWith("Module candidate:")
|
|
735
|
+
: row.kind === kind && !row.statement.startsWith("Module candidate:");
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* A heading that describes a step in a process, not a part of the product.
|
|
739
|
+
*
|
|
740
|
+
* A document about how to build products contains sections about the asking as
|
|
741
|
+
* well as sections about the product. "Step 4: Module Discovery" matches the
|
|
742
|
+
* module pattern and is followed by the questions to ask about a module, so
|
|
743
|
+
* every question under it was read as a module. Anything under a numbered step,
|
|
744
|
+
* or under a heading that names the act of interviewing, is procedure.
|
|
745
|
+
*/
|
|
746
|
+
/**
|
|
747
|
+
* Kinds a brief states in prose rather than as a list.
|
|
748
|
+
*
|
|
749
|
+
* Everything else is enumerable: you can count the features, the users, the
|
|
750
|
+
* goals. These are not counted, they are described, and a description that
|
|
751
|
+
* happens to run to two sentences is still one answer.
|
|
752
|
+
*/
|
|
753
|
+
const PROSE_KINDS = new Set(["problem", "risk"]);
|
|
754
|
+
|
|
755
|
+
const PROCEDURE_HEADING = /^step\s+\d+\b|\b(discovery|interview|questionnaire|onboarding process|question quality)\b/i;
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* A line that asks rather than states.
|
|
759
|
+
*
|
|
760
|
+
* "Which users interact with it?" is a prompt for content, never content. This
|
|
761
|
+
* is the single rule that would have prevented 33 modules named after the
|
|
762
|
+
* questions a module interview asks.
|
|
763
|
+
*/
|
|
764
|
+
const ASKS_RATHER_THAN_STATES = (statement) => {
|
|
765
|
+
const text = String(statement).trim();
|
|
766
|
+
if (text.endsWith("?")) return true;
|
|
767
|
+
// An imperative telling the reader to collect something is also a prompt.
|
|
768
|
+
return /^(ask|establish|clarify|identify|determine|decide|confirm|list|describe|define)\b/i.test(text)
|
|
769
|
+
&& !/\b(is|are|was|were|will be)\b/i.test(text);
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Rejoin a wrapped paragraph into the sentence it was written as.
|
|
774
|
+
*
|
|
775
|
+
* A brief hard wrapped at eighty columns is one paragraph to its author and
|
|
776
|
+
* several lines to a reader that splits on newlines. Read line by line, the
|
|
777
|
+
* first half of a sentence becomes a discovery item and arrives as a feature
|
|
778
|
+
* named "The outgoing chef records what is prepped, what ran out, and what the
|
|
779
|
+
* next", which is not a feature and not even a clause.
|
|
780
|
+
*
|
|
781
|
+
* Joining first also restores the rule above it: a paragraph is only taken when
|
|
782
|
+
* it is a single sentence, and that test can only be applied to a whole
|
|
783
|
+
* paragraph. Split lines each look like one sentence, so a summary paragraph
|
|
784
|
+
* restating the feature list was landing as extra features nobody asked for.
|
|
785
|
+
*
|
|
786
|
+
* Headings, bullets and blank lines all end a paragraph, because each of them
|
|
787
|
+
* means the next line starts something new.
|
|
788
|
+
*/
|
|
789
|
+
// Exported so it can be asserted on directly. The defect it exists to prevent
|
|
790
|
+
// was invisible from outside: a wrapped sentence became a feature named after
|
|
791
|
+
// half a clause, and the only way to see it was to run a full initialization and
|
|
792
|
+
// read the result.
|
|
793
|
+
export function paragraphs(text) {
|
|
794
|
+
const out = [];
|
|
795
|
+
let buffer = [];
|
|
796
|
+
const flush = () => {
|
|
797
|
+
if (buffer.length) out.push(buffer.join(" "));
|
|
798
|
+
buffer = [];
|
|
799
|
+
};
|
|
800
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
801
|
+
const line = raw.trim();
|
|
802
|
+
const starts = !line
|
|
803
|
+
|| /^#{1,6}\s+/.test(line)
|
|
804
|
+
|| /^(?:[-*+]|\d+[.)])\s+/.test(line)
|
|
805
|
+
|| /^(?:>|\||```)/.test(line);
|
|
806
|
+
if (starts) {
|
|
807
|
+
flush();
|
|
808
|
+
if (line) out.push(line);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
buffer.push(line);
|
|
812
|
+
}
|
|
813
|
+
flush();
|
|
814
|
+
return out;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Pull bullets and short paragraphs out of a Markdown or plain-text brief,
|
|
819
|
+
* classified by the heading they sit under. Anything under an unrecognised
|
|
820
|
+
* heading is skipped rather than forced into a kind it does not belong to.
|
|
821
|
+
*
|
|
822
|
+
* Two things are never taken: a line that asks a question, and anything under a
|
|
823
|
+
* heading that describes the interview rather than the product. Without those
|
|
824
|
+
* this read a requirements document's own discovery questions as the product's
|
|
825
|
+
* modules and features.
|
|
826
|
+
*/
|
|
827
|
+
export function extractFromDocument(text, limit = 120) {
|
|
828
|
+
const out = [];
|
|
829
|
+
let heading = null;
|
|
830
|
+
let kind = null;
|
|
831
|
+
let procedure = false;
|
|
832
|
+
|
|
833
|
+
for (const line of paragraphs(text)) {
|
|
834
|
+
// Only a real heading changes the section. A line ending in a colon
|
|
835
|
+
// introduces a list within the current section, so it may re-classify the
|
|
836
|
+
// kind but must not clear the procedure flag: "Every applicable area must
|
|
837
|
+
// be marked as:" sits inside a numbered process step, and treating it as a
|
|
838
|
+
// fresh heading let that step's contents back in.
|
|
839
|
+
const realHeading = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
840
|
+
// A list intro is a label, not a sentence. "A user should be able to
|
|
841
|
+
// describe a product in ordinary language and have Superdev help them:"
|
|
842
|
+
// ends in a colon and is prose, and reading it as a heading filed a vision
|
|
843
|
+
// list under "user". Sixty characters separates a label from a sentence
|
|
844
|
+
// well enough, and anything longer keeps the heading it already sits under.
|
|
845
|
+
const listIntro = realHeading ? null : line.match(/^(.{1,60}?):\s*$/);
|
|
846
|
+
if (realHeading || listIntro) {
|
|
847
|
+
heading = (realHeading ?? listIntro)[1].replace(/[#*_`]/g, "").trim();
|
|
848
|
+
// Section numbering is not part of the name. "4.1 Primary Users" has to
|
|
849
|
+
// classify the same as "Primary Users", and a heading that reads as a
|
|
850
|
+
// sentence is describing behaviour rather than labelling a list: "Users
|
|
851
|
+
// must be able to navigate" introduces navigation paths, not users.
|
|
852
|
+
const label = heading.replace(/^\d+(\.\d+)*[.)]?\s+/, "").trim();
|
|
853
|
+
const isSentence = /\b(must|should|can|will|may|able to|are|is)\b/i.test(label);
|
|
854
|
+
if (realHeading) procedure = PROCEDURE_HEADING.test(heading);
|
|
855
|
+
else procedure = procedure || PROCEDURE_HEADING.test(heading);
|
|
856
|
+
const matched = HEADING_KINDS.find(([pattern]) => pattern.test(label));
|
|
857
|
+
// A sentence heading may still carry constraints and exclusions, which are
|
|
858
|
+
// stated as rules, but it never names a population or a part of the product.
|
|
859
|
+
kind = matched && isSentence && ["user", "module_candidate", "feature_candidate", "goal_candidate"].includes(matched[1])
|
|
860
|
+
? null
|
|
861
|
+
: matched?.[1] ?? null;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (!kind || procedure) continue;
|
|
865
|
+
const bullet = line.match(/^(?:[-*+]|\d+[.)])\s+(.+)$/);
|
|
866
|
+
const statement = bullet ? bullet[1] : line;
|
|
867
|
+
if (statement.length < 4 || statement.length > MAX_STATEMENT) continue;
|
|
868
|
+
if (ASKS_RATHER_THAN_STATES(statement)) continue;
|
|
869
|
+
// A prose paragraph is taken whole for the kinds that are written as prose,
|
|
870
|
+
// and only as a single sentence for the kinds that are written as lists.
|
|
871
|
+
//
|
|
872
|
+
// The single-sentence rule exists to stop a summary paragraph under "What it
|
|
873
|
+
// does" landing as extra features, which it did. It also silently dropped
|
|
874
|
+
// the one section every brief writes as prose: a two-sentence Problem was
|
|
875
|
+
// skipped, and the report then said nothing stated what problem it solves,
|
|
876
|
+
// to somebody who had just stated it. That is worse than the bug the rule
|
|
877
|
+
// prevents, because the reader has no way to tell they were ignored.
|
|
878
|
+
//
|
|
879
|
+
// So the rule follows the kind. A problem, a motivation and a risk arrive as
|
|
880
|
+
// a paragraph. Features, modules, users and goals arrive as a list, and a
|
|
881
|
+
// paragraph among them is prose about them rather than another one.
|
|
882
|
+
if (!bullet && !PROSE_KINDS.has(kind) && splitSentences(statement).length > 1) continue;
|
|
883
|
+
out.push({ kind, statement: statement.replace(/[*_`]/g, "").trim(), heading });
|
|
884
|
+
if (out.length >= limit) break;
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const splitSentences = (text) =>
|
|
890
|
+
String(text)
|
|
891
|
+
.split(/(?<=[.!?])\s+(?=[A-Z(])/)
|
|
892
|
+
.map((s) => s.trim())
|
|
893
|
+
.filter(Boolean);
|
|
894
|
+
|
|
895
|
+
const normalize = (text) => String(text ?? "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
896
|
+
|
|
897
|
+
function clean(text) {
|
|
898
|
+
if (text === null || text === undefined) return null;
|
|
899
|
+
const out = redactSecrets(sanitizeExternal(String(text))).trim();
|
|
900
|
+
return out || null;
|
|
901
|
+
}
|