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,871 @@
|
|
|
1
|
+
// Manual-edit detection and the proposal flow. Brief section 6.2.
|
|
2
|
+
//
|
|
3
|
+
// Generated Markdown is a projection of the database, never a second writable
|
|
4
|
+
// source of truth, so a human edit is neither silently overwritten nor silently
|
|
5
|
+
// promoted. A file whose normalized body no longer hashes to
|
|
6
|
+
// `documents.generated_hash` becomes a pending proposal that a person resolves.
|
|
7
|
+
//
|
|
8
|
+
// Accepting writes the human's values into the database first and only then
|
|
9
|
+
// touches the file, because the reverse order would leave the file authoritative
|
|
10
|
+
// for however long the write took. Rejecting restores the stored generation, but
|
|
11
|
+
// only after the discarded text is recorded in an activity event, so a rejection
|
|
12
|
+
// is recoverable rather than destructive.
|
|
13
|
+
|
|
14
|
+
import { readFile, writeFile, rename, mkdir, unlink } from "node:fs/promises";
|
|
15
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
query, mutate, patch, recordActivity, currentProject, DbError,
|
|
18
|
+
} from "../db/store.mjs";
|
|
19
|
+
import { kindOf, tableFor } from "../model/ids.mjs";
|
|
20
|
+
import { assertStorable, sanitizeExternal } from "../model/screening.mjs";
|
|
21
|
+
// The renderer owns the marker grammar and the body's normal form. Both are
|
|
22
|
+
// imported rather than restated, because a second copy that drifted by one
|
|
23
|
+
// trailing newline would make every file look edited.
|
|
24
|
+
import { parseMarker, bodyHash, withMarker, normalizeBody, MARKER } from "./render.mjs";
|
|
25
|
+
|
|
26
|
+
export const E = {
|
|
27
|
+
NOT_FOUND: "E_NOT_FOUND",
|
|
28
|
+
DERIVED_VIEW: "E_DOC_DERIVED_VIEW",
|
|
29
|
+
NOT_A_PROPOSAL: "E_DOC_NOT_A_PROPOSAL",
|
|
30
|
+
NO_GENERATED_BODY: "E_DOC_NO_GENERATED_BODY",
|
|
31
|
+
OUTSIDE_PROJECT: "E_PATH_OUTSIDE_PROJECT",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const nowIso = () => new Date().toISOString();
|
|
35
|
+
|
|
36
|
+
// --------------------------------------------------------------- file access
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A document path is data from the database or from a browser, so it is
|
|
40
|
+
* confined to the project before it reaches the filesystem.
|
|
41
|
+
*/
|
|
42
|
+
function resolveInside(root, docPath) {
|
|
43
|
+
const base = resolve(root);
|
|
44
|
+
const abs = resolve(base, String(docPath ?? ""));
|
|
45
|
+
const rel = relative(base, abs);
|
|
46
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
47
|
+
throw new DbError(E.OUTSIDE_PROJECT, `${docPath} resolves outside the project directory.`);
|
|
48
|
+
}
|
|
49
|
+
return abs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Project-relative, forward slashed, which is how `documents.path` is stored. */
|
|
53
|
+
function toRelative(root, docPath) {
|
|
54
|
+
const value = String(docPath ?? "");
|
|
55
|
+
const rel = isAbsolute(value) ? relative(resolve(root), resolve(value)) : value;
|
|
56
|
+
return rel.split(sep).join("/").replace(/^\.\//, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The body of a generated file, without its marker. `parseMarker` owns the
|
|
61
|
+
* grammar and refuses a marker it cannot fully read, so a marker that a person
|
|
62
|
+
* damaged while editing is still stripped here rather than being diffed as
|
|
63
|
+
* content the human wrote.
|
|
64
|
+
*/
|
|
65
|
+
function readBody(text) {
|
|
66
|
+
const parsed = parseMarker(text);
|
|
67
|
+
if (parsed) return parsed.body;
|
|
68
|
+
const first = String(text).split("\n", 1)[0];
|
|
69
|
+
return first.includes(MARKER) ? text.slice(first.length).replace(/^\r?\n/, "") : text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeAtomic(file, text) {
|
|
73
|
+
await mkdir(dirname(file), { recursive: true });
|
|
74
|
+
const tmp = `${file}.superdev-${process.pid}.tmp`;
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(tmp, text, "utf8");
|
|
77
|
+
await rename(tmp, file);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
await unlink(tmp).catch(() => {});
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------- inspection
|
|
85
|
+
|
|
86
|
+
async function inspect(root, doc) {
|
|
87
|
+
const file = resolveInside(root, doc.path);
|
|
88
|
+
let text = null;
|
|
89
|
+
try {
|
|
90
|
+
text = await readFile(file, "utf8");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err.code !== "ENOENT") throw err;
|
|
93
|
+
}
|
|
94
|
+
if (text === null) return { doc, file, body: null, diskHash: null, status: "missing" };
|
|
95
|
+
const body = readBody(text);
|
|
96
|
+
const diskHash = bodyHash(body);
|
|
97
|
+
// A row with no recorded hash has never been generated, so anything on disk
|
|
98
|
+
// at that path is a human's file and is treated as an edit, not as ours.
|
|
99
|
+
const status = diskHash === doc.generated_hash ? "in_sync" : "manual_edit_pending";
|
|
100
|
+
return { doc, file, body, diskHash, status };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const entryOf = (state) => ({
|
|
104
|
+
id: state.doc.id,
|
|
105
|
+
path: state.doc.path,
|
|
106
|
+
kind: state.doc.kind,
|
|
107
|
+
template: state.doc.template,
|
|
108
|
+
scopeType: state.doc.scope_type,
|
|
109
|
+
scopeId: state.doc.scope_id,
|
|
110
|
+
status: state.status,
|
|
111
|
+
recordedStatus: state.doc.sync_status,
|
|
112
|
+
generatedHash: state.doc.generated_hash,
|
|
113
|
+
diskHash: state.diskHash,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An in-sync file only clears a stale pending or missing flag. An `accepted` or
|
|
118
|
+
* `rejected` resolution stays on the row until the next real generation, so the
|
|
119
|
+
* control center can still show how the last proposal ended.
|
|
120
|
+
*/
|
|
121
|
+
function effectiveStatus(state) {
|
|
122
|
+
if (state.status !== "in_sync") return state.status;
|
|
123
|
+
return ["manual_edit_pending", "missing"].includes(state.doc.sync_status)
|
|
124
|
+
? "generated"
|
|
125
|
+
: state.doc.sync_status;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scan every authored projection and report the ones whose file no longer
|
|
130
|
+
* matches the generation it came from. Persists the pending state and raises
|
|
131
|
+
* `documentation_proposal_raised` once per distinct edit, so rescanning is
|
|
132
|
+
* silent instead of filling the feed with the same proposal.
|
|
133
|
+
*/
|
|
134
|
+
export async function detectProposals(root, { apply = true, actor = "superdev" } = {}) {
|
|
135
|
+
// Deliberately not `SELECT *`: the control center polls the summary, and
|
|
136
|
+
// `generated_body` is the one column a scan never reads.
|
|
137
|
+
const all = await query(root, (db) =>
|
|
138
|
+
db.all(
|
|
139
|
+
`SELECT id, project_id, kind, scope_type, scope_id, path, template,
|
|
140
|
+
regeneration_mode, generated_hash, manual_hash, sync_status
|
|
141
|
+
FROM documents ORDER BY path`,
|
|
142
|
+
));
|
|
143
|
+
// A retired document described a record that no longer exists, and its file
|
|
144
|
+
// was removed on purpose. Inspecting one finds the file absent and reports it
|
|
145
|
+
// missing, which turns a finished document into an open decision nobody can
|
|
146
|
+
// close: regenerating will never bring it back, because nothing generates it
|
|
147
|
+
// any more.
|
|
148
|
+
const authored = all.filter((d) =>
|
|
149
|
+
d.regeneration_mode === "authored_projection" && d.sync_status !== "retired");
|
|
150
|
+
|
|
151
|
+
const states = [];
|
|
152
|
+
for (const doc of authored) states.push(await inspect(root, doc));
|
|
153
|
+
|
|
154
|
+
const writes = [];
|
|
155
|
+
for (const state of states) {
|
|
156
|
+
const next = effectiveStatus(state);
|
|
157
|
+
const manualHash = state.status === "manual_edit_pending" ? state.diskHash : null;
|
|
158
|
+
if (next === state.doc.sync_status && manualHash === (state.doc.manual_hash ?? null)) continue;
|
|
159
|
+
writes.push({
|
|
160
|
+
state,
|
|
161
|
+
next,
|
|
162
|
+
manualHash,
|
|
163
|
+
raise: state.status !== "in_sync",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const raised = [];
|
|
168
|
+
if (apply && writes.length) {
|
|
169
|
+
await mutate(root, async (db) => {
|
|
170
|
+
for (const w of writes) {
|
|
171
|
+
// `documents` carries no optimistic version, so `patch` cannot drive it.
|
|
172
|
+
// The activity event that keeps this honest is written right here.
|
|
173
|
+
await db.run(
|
|
174
|
+
"UPDATE documents SET sync_status = ?, manual_hash = ? WHERE id = ?",
|
|
175
|
+
w.next, w.manualHash, w.state.doc.id,
|
|
176
|
+
);
|
|
177
|
+
if (!w.raise) continue;
|
|
178
|
+
await recordActivity(db, w.state.doc.project_id, {
|
|
179
|
+
type: "documentation_proposal_raised",
|
|
180
|
+
summary:
|
|
181
|
+
w.state.status === "missing"
|
|
182
|
+
? `${w.state.doc.path} is missing and no longer matches the database`
|
|
183
|
+
: `${w.state.doc.path} was edited by hand and is waiting for a decision`,
|
|
184
|
+
actor,
|
|
185
|
+
metadata: {
|
|
186
|
+
document_id: w.state.doc.id,
|
|
187
|
+
path: w.state.doc.path,
|
|
188
|
+
reason: w.state.status,
|
|
189
|
+
generated_hash: w.state.doc.generated_hash,
|
|
190
|
+
disk_hash: w.state.diskHash,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
raised.push(w.state.doc.id);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const byStatus = {};
|
|
199
|
+
for (const state of states) {
|
|
200
|
+
const key = effectiveStatus(state);
|
|
201
|
+
byStatus[key] = (byStatus[key] ?? 0) + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
scanned: authored.length,
|
|
206
|
+
derivedViews: all.length - authored.length,
|
|
207
|
+
inSync: states.filter((s) => s.status === "in_sync").length,
|
|
208
|
+
proposals: states.filter((s) => s.status !== "in_sync").map(entryOf),
|
|
209
|
+
byStatus,
|
|
210
|
+
raised,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Counts for the control center. Read only: nothing is written or raised. */
|
|
215
|
+
export async function proposalSummary(root) {
|
|
216
|
+
const report = await detectProposals(root, { apply: false });
|
|
217
|
+
const pending = report.proposals.filter((p) => p.status === "manual_edit_pending");
|
|
218
|
+
const missing = report.proposals.filter((p) => p.status === "missing");
|
|
219
|
+
return {
|
|
220
|
+
documents: report.scanned,
|
|
221
|
+
derivedViews: report.derivedViews,
|
|
222
|
+
inSync: report.inSync,
|
|
223
|
+
pending: pending.length,
|
|
224
|
+
missing: missing.length,
|
|
225
|
+
accepted: report.byStatus.accepted ?? 0,
|
|
226
|
+
rejected: report.byStatus.rejected ?? 0,
|
|
227
|
+
open: report.proposals.length,
|
|
228
|
+
paths: report.proposals.map((p) => p.path),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------- diffing
|
|
233
|
+
|
|
234
|
+
const splitLines = (text) => (text === "" ? [] : text.replace(/\n$/, "").split("\n"));
|
|
235
|
+
|
|
236
|
+
// ponytail: plain O(n*m) LCS. Documents are hundreds of lines, so the matrix is
|
|
237
|
+
// cheap; past the cap the diff degrades to one replace hunk rather than
|
|
238
|
+
// allocating hundreds of megabytes. Swap in a Myers diff only if that cap is
|
|
239
|
+
// ever reached in practice.
|
|
240
|
+
const DIFF_CELL_CAP = 4_000_000;
|
|
241
|
+
|
|
242
|
+
function diffLines(a, b) {
|
|
243
|
+
const ops = [];
|
|
244
|
+
if ((a.length + 1) * (b.length + 1) > DIFF_CELL_CAP) {
|
|
245
|
+
a.forEach((line, i) => ops.push({ op: "-", line, ai: i, bi: -1 }));
|
|
246
|
+
b.forEach((line, j) => ops.push({ op: "+", line, ai: -1, bi: j }));
|
|
247
|
+
return ops;
|
|
248
|
+
}
|
|
249
|
+
const w = b.length + 1;
|
|
250
|
+
const dp = new Int32Array((a.length + 1) * w);
|
|
251
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
252
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
253
|
+
dp[i * w + j] = a[i] === b[j]
|
|
254
|
+
? dp[(i + 1) * w + j + 1] + 1
|
|
255
|
+
: Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
let i = 0;
|
|
259
|
+
let j = 0;
|
|
260
|
+
while (i < a.length && j < b.length) {
|
|
261
|
+
if (a[i] === b[j]) {
|
|
262
|
+
ops.push({ op: " ", line: a[i], ai: i, bi: j });
|
|
263
|
+
i++;
|
|
264
|
+
j++;
|
|
265
|
+
} else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
|
|
266
|
+
ops.push({ op: "-", line: a[i], ai: i, bi: -1 });
|
|
267
|
+
i++;
|
|
268
|
+
} else {
|
|
269
|
+
ops.push({ op: "+", line: b[j], ai: -1, bi: j });
|
|
270
|
+
j++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
while (i < a.length) ops.push({ op: "-", line: a[i], ai: i++, bi: -1 });
|
|
274
|
+
while (j < b.length) ops.push({ op: "+", line: b[j], ai: -1, bi: j++ });
|
|
275
|
+
return ops;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function unified(ops, fromName, toName, context = 3) {
|
|
279
|
+
const keep = new Array(ops.length).fill(false);
|
|
280
|
+
ops.forEach((op, k) => {
|
|
281
|
+
if (op.op === " ") return;
|
|
282
|
+
for (let d = -context; d <= context; d++) if (k + d >= 0 && k + d < ops.length) keep[k + d] = true;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const out = [];
|
|
286
|
+
let k = 0;
|
|
287
|
+
while (k < ops.length) {
|
|
288
|
+
if (!keep[k]) {
|
|
289
|
+
k++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
let end = k;
|
|
293
|
+
while (end < ops.length && keep[end]) end++;
|
|
294
|
+
let aStart = null;
|
|
295
|
+
let bStart = null;
|
|
296
|
+
let aCount = 0;
|
|
297
|
+
let bCount = 0;
|
|
298
|
+
for (let x = k; x < end; x++) {
|
|
299
|
+
const op = ops[x];
|
|
300
|
+
if (op.op !== "+") {
|
|
301
|
+
if (aStart === null) aStart = op.ai;
|
|
302
|
+
aCount++;
|
|
303
|
+
}
|
|
304
|
+
if (op.op !== "-") {
|
|
305
|
+
if (bStart === null) bStart = op.bi;
|
|
306
|
+
bCount++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
out.push(
|
|
310
|
+
`@@ -${aCount === 0 ? 0 : (aStart ?? 0) + 1},${aCount} +${bCount === 0 ? 0 : (bStart ?? 0) + 1},${bCount} @@`,
|
|
311
|
+
);
|
|
312
|
+
for (let x = k; x < end; x++) out.push(ops[x].op + ops[x].line);
|
|
313
|
+
k = end;
|
|
314
|
+
}
|
|
315
|
+
if (!out.length) return "";
|
|
316
|
+
return [`--- ${fromName}`, `+++ ${toName}`, ...out].join("\n") + "\n";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------- sections
|
|
320
|
+
|
|
321
|
+
const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
322
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
323
|
+
const PREAMBLE = "(document preamble)";
|
|
324
|
+
|
|
325
|
+
/** Flat section list. A heading inside a fenced code block is not a heading. */
|
|
326
|
+
function sectionsOf(lines) {
|
|
327
|
+
const out = [{ heading: null, level: 0, start: 0, end: lines.length }];
|
|
328
|
+
let fenced = false;
|
|
329
|
+
for (let i = 0; i < lines.length; i++) {
|
|
330
|
+
if (FENCE.test(lines[i])) fenced = !fenced;
|
|
331
|
+
if (fenced) continue;
|
|
332
|
+
const m = HEADING.exec(lines[i]);
|
|
333
|
+
if (!m) continue;
|
|
334
|
+
out[out.length - 1].end = i;
|
|
335
|
+
out.push({ heading: m[2], level: m[1].length, start: i, end: lines.length });
|
|
336
|
+
}
|
|
337
|
+
for (const s of out) s.lines = lines.slice(s.heading === null ? s.start : s.start + 1, s.end);
|
|
338
|
+
return out[0].end === 0 && out.length > 1 ? out.slice(1) : out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const sectionAt = (sections, index) =>
|
|
342
|
+
sections.find((s) => index >= s.start && index < s.end) ?? sections[0];
|
|
343
|
+
|
|
344
|
+
function touchedSections(ops, aLines, bLines) {
|
|
345
|
+
const before = sectionsOf(aLines);
|
|
346
|
+
const after = sectionsOf(bLines);
|
|
347
|
+
const seen = new Map();
|
|
348
|
+
for (const op of ops) {
|
|
349
|
+
if (op.op === " ") continue;
|
|
350
|
+
const section = op.op === "-" ? sectionAt(before, op.ai) : sectionAt(after, op.bi);
|
|
351
|
+
const key = section?.heading ?? PREAMBLE;
|
|
352
|
+
const row = seen.get(key) ?? { heading: key, level: section?.level ?? 0, added: 0, removed: 0 };
|
|
353
|
+
if (op.op === "-") row.removed++;
|
|
354
|
+
else row.added++;
|
|
355
|
+
seen.set(key, row);
|
|
356
|
+
}
|
|
357
|
+
return [...seen.values()];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------- documents
|
|
361
|
+
|
|
362
|
+
async function loadDocument(root, path) {
|
|
363
|
+
const rel = toRelative(root, path);
|
|
364
|
+
const doc = await query(root, (db) => db.get("SELECT * FROM documents WHERE path = ?", rel));
|
|
365
|
+
if (!doc) {
|
|
366
|
+
throw new DbError(
|
|
367
|
+
E.NOT_FOUND,
|
|
368
|
+
`No generated document is recorded at ${rel}. Generate documentation before reviewing an edit to it.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (doc.regeneration_mode !== "authored_projection") {
|
|
372
|
+
throw new DbError(
|
|
373
|
+
E.DERIVED_VIEW,
|
|
374
|
+
`${rel} is a derived view. It is rewritten on every generation and never holds a manual edit.`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return doc;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A readable unified diff between the stored generation and what is on disk,
|
|
382
|
+
* plus the template sections the edit touched.
|
|
383
|
+
*/
|
|
384
|
+
export async function diffProposal(root, path) {
|
|
385
|
+
const doc = await loadDocument(root, path);
|
|
386
|
+
const state = await inspect(root, doc);
|
|
387
|
+
const before = splitLines(normalizeBody(doc.generated_body ?? ""));
|
|
388
|
+
const after = state.body === null ? [] : splitLines(normalizeBody(state.body));
|
|
389
|
+
const ops = diffLines(before, after);
|
|
390
|
+
return {
|
|
391
|
+
id: doc.id,
|
|
392
|
+
path: doc.path,
|
|
393
|
+
status: state.status,
|
|
394
|
+
generatedHash: doc.generated_hash,
|
|
395
|
+
diskHash: state.diskHash,
|
|
396
|
+
added: ops.filter((o) => o.op === "+").length,
|
|
397
|
+
removed: ops.filter((o) => o.op === "-").length,
|
|
398
|
+
sections: touchedSections(ops, before, after),
|
|
399
|
+
diff: unified(ops, `database ${doc.id} ${doc.path}`, `working tree ${doc.path}`),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ------------------------------------------------------------ field mapping
|
|
404
|
+
|
|
405
|
+
const normalizeHeading = (heading) =>
|
|
406
|
+
String(heading ?? "")
|
|
407
|
+
.toLowerCase()
|
|
408
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
409
|
+
.replace(/^_+|_+$/g, "");
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Only the template's phrasings that do not already normalize onto a column
|
|
413
|
+
* name. Everything else resolves by name, so a new column is readable without
|
|
414
|
+
* an entry here.
|
|
415
|
+
*/
|
|
416
|
+
const ALIAS = {
|
|
417
|
+
why: "purpose",
|
|
418
|
+
overview: "purpose",
|
|
419
|
+
product: "statement",
|
|
420
|
+
user: "user_statement",
|
|
421
|
+
user_story: "user_statement",
|
|
422
|
+
value: "user_value",
|
|
423
|
+
in_scope: "scope_in",
|
|
424
|
+
out_of_scope: "scope_out",
|
|
425
|
+
rationale: "observable_rationale",
|
|
426
|
+
problem_statement: "problem",
|
|
427
|
+
completion: "completion_criteria",
|
|
428
|
+
telemetry: "observability",
|
|
429
|
+
accessibility: "accessibility_notes",
|
|
430
|
+
responsive: "responsive_behavior",
|
|
431
|
+
next: "next_action",
|
|
432
|
+
what_is_left: "what_remains",
|
|
433
|
+
what_works_today: "what_works_now",
|
|
434
|
+
how_it_is_verified: "verification",
|
|
435
|
+
implemented_at: "implementation_path",
|
|
436
|
+
owner_or_approver: "accepted_by",
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Identity, lifecycle and derived columns are never taken from Markdown. A
|
|
440
|
+
// status moves through `setStatus` so its transition is recorded, a slug and a
|
|
441
|
+
// name decide the file's own path, and timestamps are not prose.
|
|
442
|
+
const NEVER = new Set([
|
|
443
|
+
"id", "project_id", "version", "slug", "name", "title", "status", "sequence",
|
|
444
|
+
"position_x", "position_y", "body_hash", "database_revision",
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
const deniedColumn = (column, constrained) =>
|
|
448
|
+
NEVER.has(column) ||
|
|
449
|
+
constrained.has(column) ||
|
|
450
|
+
column.endsWith("_id") ||
|
|
451
|
+
column.endsWith("_at") ||
|
|
452
|
+
column.endsWith("_hash");
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Columns the schema pins to a fixed set. Read from the schema itself so a new
|
|
456
|
+
* enumerated column cannot quietly become writable from a Markdown heading.
|
|
457
|
+
*/
|
|
458
|
+
async function constrainedColumns(db, table) {
|
|
459
|
+
const sql = await db.value("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table);
|
|
460
|
+
const found = new Set();
|
|
461
|
+
for (const m of String(sql ?? "").matchAll(/CHECK\s*\(\s*(\w+)\s+IN\s*\(/gi)) found.add(m[1]);
|
|
462
|
+
return found;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function bulletItems(lines) {
|
|
466
|
+
const items = [];
|
|
467
|
+
for (const line of lines) {
|
|
468
|
+
if (!line.trim()) continue;
|
|
469
|
+
const m = /^[-*]\s+(.*\S)\s*$/.exec(line);
|
|
470
|
+
if (!m) return null;
|
|
471
|
+
items.push(m[1]);
|
|
472
|
+
}
|
|
473
|
+
return items.length ? items : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The value a section would write into a column. A JSON column only accepts a
|
|
478
|
+
* flat bullet list, which is the one shape that survives the round trip back
|
|
479
|
+
* out through the renderer. Anything else stays unmapped rather than guessed.
|
|
480
|
+
*/
|
|
481
|
+
function valueFor(column, lines) {
|
|
482
|
+
if (column.endsWith("_json")) {
|
|
483
|
+
const items = bulletItems(lines);
|
|
484
|
+
return items ? JSON.stringify(items) : undefined;
|
|
485
|
+
}
|
|
486
|
+
return lines.join("\n").trim();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function columnFor(name, columns, constrained) {
|
|
490
|
+
const key = normalizeHeading(name);
|
|
491
|
+
if (!key) return null;
|
|
492
|
+
const base = ALIAS[key] ?? key;
|
|
493
|
+
for (const candidate of [base, `${base}_json`, key, `${key}_json`]) {
|
|
494
|
+
if (columns.has(candidate) && !deniedColumn(candidate, constrained)) return candidate;
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The templates put a scalar field on its own `- **Name:** value` line and a
|
|
500
|
+
// prose field under its own heading. Those are the only two shapes a document
|
|
501
|
+
// can be read back through; a table row belongs to child records that the
|
|
502
|
+
// document does not address.
|
|
503
|
+
const LABEL_LINE = /^-\s+\*\*([^*]+?):\*\*\s*(.*)$/;
|
|
504
|
+
const TABLE_ROW = /^\s*\|/;
|
|
505
|
+
|
|
506
|
+
/** The renderer escapes pipes so a value cannot break a table. */
|
|
507
|
+
const unescapePipes = (value) => String(value).replace(/\\\|/g, "|");
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* What the edit is proposing, per changed line. A line that is neither a label
|
|
511
|
+
* nor part of a prose section is left for `planAcceptance` to refuse by name,
|
|
512
|
+
* so nothing is interpreted just because it moved.
|
|
513
|
+
*/
|
|
514
|
+
function proposedChanges(beforeBody, afterBody) {
|
|
515
|
+
const beforeLines = splitLines(normalizeBody(beforeBody));
|
|
516
|
+
const afterLines = splitLines(normalizeBody(afterBody));
|
|
517
|
+
const beforeSections = sectionsOf(beforeLines);
|
|
518
|
+
const afterSections = sectionsOf(afterLines);
|
|
519
|
+
|
|
520
|
+
const added = new Map();
|
|
521
|
+
const removed = new Map();
|
|
522
|
+
const prose = new Map();
|
|
523
|
+
|
|
524
|
+
for (const op of diffLines(beforeLines, afterLines)) {
|
|
525
|
+
if (op.op === " ") continue;
|
|
526
|
+
const isAdd = op.op === "+";
|
|
527
|
+
const section = isAdd ? sectionAt(afterSections, op.bi) : sectionAt(beforeSections, op.ai);
|
|
528
|
+
const heading = section?.heading ?? null;
|
|
529
|
+
const m = LABEL_LINE.exec(op.line);
|
|
530
|
+
if (m) {
|
|
531
|
+
const name = m[1].trim();
|
|
532
|
+
(isAdd ? added : removed).set(`${heading}${name.toLowerCase()}`, {
|
|
533
|
+
heading, name, value: unescapePipes(m[2]).trim(),
|
|
534
|
+
});
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (prose.has(heading)) continue;
|
|
538
|
+
prose.set(heading, { heading, section: afterSections.find((s) => s.heading === heading) ?? null });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// A label that was rewritten shows up as a removal and an addition of the
|
|
542
|
+
// same name. Only the ones with no replacement are real deletions.
|
|
543
|
+
for (const key of added.keys()) removed.delete(key);
|
|
544
|
+
|
|
545
|
+
return { labels: [...added.values()], deleted: [...removed.values()], prose: [...prose.values()] };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Work out what accepting the on-disk file would write. Read only, so the same
|
|
550
|
+
* plan drives a dry run and the real thing.
|
|
551
|
+
*/
|
|
552
|
+
async function planAcceptance(db, doc, state) {
|
|
553
|
+
const applied = [];
|
|
554
|
+
const unmapped = [];
|
|
555
|
+
const values = {};
|
|
556
|
+
const reject = (where, reason) => unmapped.push({ section: where ?? PREAMBLE, reason });
|
|
557
|
+
// Diffing against the empty string would report every line of the document as
|
|
558
|
+
// newly proposed, including the renderer's own "Not recorded" placeholders,
|
|
559
|
+
// and those would then be written into the record as if a person had typed
|
|
560
|
+
// them. Without a cached body there is nothing to compare and nothing safe to
|
|
561
|
+
// apply.
|
|
562
|
+
if (typeof doc.generated_body !== "string") {
|
|
563
|
+
throw new DocsError(
|
|
564
|
+
E.NO_GENERATED_BODY,
|
|
565
|
+
`${doc.path} has no stored copy of what was generated, so there is nothing to compare this edit against. Run docs generate to rebuild it, then read the difference again.`,
|
|
566
|
+
{ path: doc.path },
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
const changes = proposedChanges(doc.generated_body, state.body ?? "");
|
|
570
|
+
|
|
571
|
+
const scopeId = doc.scope_id || (await currentProject(db))?.id;
|
|
572
|
+
const kind = scopeId ? kindOf(scopeId) : null;
|
|
573
|
+
const table = kind ? tableFor(kind) : null;
|
|
574
|
+
const record = table ? await db.get(`SELECT * FROM ${table} WHERE id = ?`, scopeId) : null;
|
|
575
|
+
|
|
576
|
+
const everything = [
|
|
577
|
+
...changes.labels.map((l) => `${l.heading ?? PREAMBLE} / ${l.name}`),
|
|
578
|
+
...changes.deleted.map((l) => `${l.heading ?? PREAMBLE} / ${l.name}`),
|
|
579
|
+
...changes.prose.map((p) => p.heading ?? PREAMBLE),
|
|
580
|
+
];
|
|
581
|
+
if (!record) {
|
|
582
|
+
for (const where of everything) reject(where, `${doc.path} is not attached to a record this flow can update.`);
|
|
583
|
+
return { applied, unmapped, record: null, kind, values };
|
|
584
|
+
}
|
|
585
|
+
if (record.version === undefined) {
|
|
586
|
+
for (const where of everything) {
|
|
587
|
+
reject(where, `${kind} ${scopeId} has no optimistic version, so it cannot be updated from a document.`);
|
|
588
|
+
}
|
|
589
|
+
return { applied, unmapped, record: null, kind, values };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const columns = new Set((await db.all(`PRAGMA table_info(${table})`)).map((c) => c.name));
|
|
593
|
+
const constrained = await constrainedColumns(db, table);
|
|
594
|
+
|
|
595
|
+
const take = (where, column, value) => {
|
|
596
|
+
if (values[column] !== undefined && values[column] !== value) {
|
|
597
|
+
reject(where, `${column} is edited in two places at once, so the intended value is ambiguous.`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
assertStorable(column, value);
|
|
602
|
+
} catch (err) {
|
|
603
|
+
reject(where, err.message);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
values[column] = value === "" ? null : value;
|
|
607
|
+
applied.push({ section: where, column, kind, recordId: record.id });
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
for (const item of changes.labels) {
|
|
611
|
+
const where = `${item.heading ?? PREAMBLE} / ${item.name}`;
|
|
612
|
+
const column = columnFor(item.name, columns, constrained);
|
|
613
|
+
if (!column) {
|
|
614
|
+
reject(where, `No ${kind} field is stored behind "${item.name}", so this line cannot be accepted.`);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (column.endsWith("_json")) {
|
|
618
|
+
reject(where, `${column} holds structured items. Edit them in Superdev; one comma separated line cannot be split back apart safely.`);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
take(where, column, item.value);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
for (const item of changes.deleted) {
|
|
625
|
+
reject(
|
|
626
|
+
`${item.heading ?? PREAMBLE} / ${item.name}`,
|
|
627
|
+
"This line was removed. Clear the value in Superdev rather than deleting the line.",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const item of changes.prose) {
|
|
632
|
+
const where = item.heading ?? PREAMBLE;
|
|
633
|
+
if (!item.section) {
|
|
634
|
+
reject(where, "This section was removed. Clear the value in Superdev rather than deleting the section.");
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const column = item.heading ? columnFor(item.heading, columns, constrained) : null;
|
|
638
|
+
if (!column) {
|
|
639
|
+
reject(where, `No ${kind} field is projected from this section, so its edit cannot be stored.`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (item.section.lines.some((line) => TABLE_ROW.test(line) || LABEL_LINE.test(line))) {
|
|
643
|
+
reject(where, "This section mixes a stored field with child records, so it cannot be read back as one value.");
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const value = valueFor(column, item.section.lines);
|
|
647
|
+
if (value === undefined) {
|
|
648
|
+
reject(where, `${column} holds a list, and only a flat bullet list can be read back into it.`);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
take(where, column, value);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return { applied, unmapped, record, kind, values };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ----------------------------------------------------------------- decisions
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Apply a human edit as the new truth. The mapped sections go into the database
|
|
661
|
+
* first; the file is only touched once every changed section landed, so an edit
|
|
662
|
+
* that is partly unmappable is never half-overwritten.
|
|
663
|
+
*/
|
|
664
|
+
export async function acceptProposal(root, path, { apply = true, actor = "superdev" } = {}) {
|
|
665
|
+
const doc = await loadDocument(root, path);
|
|
666
|
+
const state = await inspect(root, doc);
|
|
667
|
+
if (state.status === "missing") {
|
|
668
|
+
throw new DbError(
|
|
669
|
+
E.NOT_FOUND,
|
|
670
|
+
`${doc.path} does not exist, so there is no manual edit to accept. Reject the proposal to write it back from the database.`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (state.status === "in_sync") {
|
|
674
|
+
throw new DbError(
|
|
675
|
+
E.NOT_A_PROPOSAL,
|
|
676
|
+
`${doc.path} already matches the database, so there is nothing to accept.`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const acceptedBody = normalizeBody(state.body);
|
|
681
|
+
|
|
682
|
+
if (!apply) {
|
|
683
|
+
const plan = await query(root, (db) => planAcceptance(db, doc, state));
|
|
684
|
+
return {
|
|
685
|
+
id: doc.id,
|
|
686
|
+
path: doc.path,
|
|
687
|
+
applied: false,
|
|
688
|
+
resolved: false,
|
|
689
|
+
wouldApply: plan.applied,
|
|
690
|
+
unmapped: plan.unmapped,
|
|
691
|
+
message: describe(plan, doc),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const result = await mutate(root, async (db) => {
|
|
696
|
+
const plan = await planAcceptance(db, doc, state);
|
|
697
|
+
|
|
698
|
+
if (plan.applied.length) {
|
|
699
|
+
await patch(db, plan.kind, plan.record.id, plan.record.version, plan.values, {
|
|
700
|
+
projectId: doc.project_id,
|
|
701
|
+
actor,
|
|
702
|
+
activitySummary: `Accepted manual edits to ${doc.path} into ${plan.kind} ${plan.record.id}`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (plan.unmapped.length || !plan.applied.length) {
|
|
707
|
+
// Part of the edit has nowhere to go, so the file keeps the human's text
|
|
708
|
+
// and the proposal stays open. Recording that here means the row never
|
|
709
|
+
// claims to be resolved while the file still differs.
|
|
710
|
+
await db.run(
|
|
711
|
+
"UPDATE documents SET sync_status = 'manual_edit_pending', manual_hash = ? WHERE id = ?",
|
|
712
|
+
state.diskHash, doc.id,
|
|
713
|
+
);
|
|
714
|
+
return { plan, revision: null };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Every changed section reached a column, so the file and the database now
|
|
718
|
+
// say the same thing. The body is stored as the current generation and
|
|
719
|
+
// restamped; the next full generation run re-derives it from these values.
|
|
720
|
+
const hash = bodyHash(acceptedBody);
|
|
721
|
+
const event = await recordActivity(db, doc.project_id, {
|
|
722
|
+
type: "documentation_proposal_resolved",
|
|
723
|
+
summary: `Accepted the manual edit to ${doc.path}`,
|
|
724
|
+
actor,
|
|
725
|
+
metadata: {
|
|
726
|
+
document_id: doc.id,
|
|
727
|
+
path: doc.path,
|
|
728
|
+
resolution: "accepted",
|
|
729
|
+
record_id: plan.record.id,
|
|
730
|
+
fields: plan.applied.map((a) => a.column),
|
|
731
|
+
previous_hash: doc.generated_hash,
|
|
732
|
+
accepted_hash: hash,
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
await db.run(
|
|
736
|
+
`UPDATE documents
|
|
737
|
+
SET sync_status = 'accepted', manual_hash = NULL, generated_body = ?,
|
|
738
|
+
generated_hash = ?, generated_at = ?, database_revision = ?
|
|
739
|
+
WHERE id = ?`,
|
|
740
|
+
acceptedBody, hash, nowIso(), event.sequence, doc.id,
|
|
741
|
+
);
|
|
742
|
+
return { plan, revision: event.sequence, hash };
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const { plan, revision } = result;
|
|
746
|
+
if (revision !== null) {
|
|
747
|
+
await writeAtomic(
|
|
748
|
+
state.file,
|
|
749
|
+
withMarker({ sourceId: doc.scope_id ?? doc.project_id, revision, body: acceptedBody }),
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
id: doc.id,
|
|
755
|
+
path: doc.path,
|
|
756
|
+
applied: plan.applied,
|
|
757
|
+
unmapped: plan.unmapped,
|
|
758
|
+
resolved: revision !== null,
|
|
759
|
+
revision,
|
|
760
|
+
message: describe(plan, doc),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Tense neutral, because a dry run and a real acceptance report the same plan. */
|
|
765
|
+
function describe(plan, doc) {
|
|
766
|
+
if (!plan.applied.length && !plan.unmapped.length) {
|
|
767
|
+
return `Nothing in ${doc.path} maps to a stored field.`;
|
|
768
|
+
}
|
|
769
|
+
const names = plan.unmapped.map((u) => u.section).join(", ");
|
|
770
|
+
if (!plan.unmapped.length) {
|
|
771
|
+
return `${plan.applied.length} edited section(s) map onto ${plan.kind} ${plan.record.id}.`;
|
|
772
|
+
}
|
|
773
|
+
return plan.applied.length
|
|
774
|
+
? `${plan.applied.length} section(s) map onto ${plan.kind} ${plan.record.id}. ${doc.path} is left as it is, because these sections could not be applied: ${names}.`
|
|
775
|
+
: `Nothing can be applied. These sections have nowhere to be stored: ${names}.`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Discard the edit and put the stored generation back. The discarded text is
|
|
780
|
+
* recorded first, so a rejection is recoverable from the activity feed rather
|
|
781
|
+
* than being the one operation that destroys a person's work.
|
|
782
|
+
*/
|
|
783
|
+
/** Longest discarded body kept in history. Enough to recognise, not to archive. */
|
|
784
|
+
const HISTORY_BODY_LIMIT = 2000;
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* What is safe to keep forever. The text is screened for credentials and home
|
|
788
|
+
* paths and then capped, because the table it lands in cannot be edited or
|
|
789
|
+
* deleted afterwards.
|
|
790
|
+
*/
|
|
791
|
+
function clipForHistory(body) {
|
|
792
|
+
const clean = sanitizeExternal(String(body ?? ""));
|
|
793
|
+
return clean.length > HISTORY_BODY_LIMIT
|
|
794
|
+
? `${clean.slice(0, HISTORY_BODY_LIMIT)}\n[truncated, ${clean.length - HISTORY_BODY_LIMIT} more characters]`
|
|
795
|
+
: clean;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export async function rejectProposal(root, path, { apply = true, actor = "superdev" } = {}) {
|
|
799
|
+
const doc = await loadDocument(root, path);
|
|
800
|
+
const state = await inspect(root, doc);
|
|
801
|
+
if (state.status === "in_sync") {
|
|
802
|
+
throw new DbError(
|
|
803
|
+
E.NOT_A_PROPOSAL,
|
|
804
|
+
`${doc.path} already matches the database, so there is no edit to discard.`,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
if (doc.generated_body === null || doc.generated_body === undefined) {
|
|
808
|
+
throw new DbError(
|
|
809
|
+
E.NO_GENERATED_BODY,
|
|
810
|
+
`${doc.path} has no stored generation to restore. Regenerate documentation before rejecting this edit.`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const body = normalizeBody(doc.generated_body);
|
|
815
|
+
const hash = bodyHash(body);
|
|
816
|
+
|
|
817
|
+
if (!apply) {
|
|
818
|
+
return {
|
|
819
|
+
id: doc.id,
|
|
820
|
+
path: doc.path,
|
|
821
|
+
applied: false,
|
|
822
|
+
resolved: false,
|
|
823
|
+
discardedHash: state.diskHash,
|
|
824
|
+
message: `Would discard the edit to ${doc.path} and restore the generated file.`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const revision = await mutate(root, async (db) => {
|
|
829
|
+
const event = await recordActivity(db, doc.project_id, {
|
|
830
|
+
type: "documentation_proposal_resolved",
|
|
831
|
+
summary: `Discarded the manual edit to ${doc.path} and restored the generated file`,
|
|
832
|
+
actor,
|
|
833
|
+
metadata: {
|
|
834
|
+
document_id: doc.id,
|
|
835
|
+
path: doc.path,
|
|
836
|
+
resolution: "rejected",
|
|
837
|
+
discarded_hash: state.diskHash,
|
|
838
|
+
// Kept in full. A rejection that could not be undone would make the
|
|
839
|
+
// proposal flow more dangerous than the silent overwrite it replaces.
|
|
840
|
+
// Sanitized and capped before it enters an append-only table. A hand
|
|
841
|
+
// edit can contain anything, activity_events refuses UPDATE and DELETE,
|
|
842
|
+
// and there is no code path in this repository that could redact a
|
|
843
|
+
// credential once it lands there.
|
|
844
|
+
discarded_body: clipForHistory(state.body),
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
await db.run(
|
|
848
|
+
`UPDATE documents
|
|
849
|
+
SET sync_status = 'rejected', manual_hash = NULL, generated_hash = ?,
|
|
850
|
+
generated_at = ?, database_revision = ?
|
|
851
|
+
WHERE id = ?`,
|
|
852
|
+
hash, nowIso(), event.sequence, doc.id,
|
|
853
|
+
);
|
|
854
|
+
return event.sequence;
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
await writeAtomic(
|
|
858
|
+
state.file,
|
|
859
|
+
withMarker({ sourceId: doc.scope_id ?? doc.project_id, revision, body }),
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
id: doc.id,
|
|
864
|
+
path: doc.path,
|
|
865
|
+
applied: true,
|
|
866
|
+
resolved: true,
|
|
867
|
+
revision,
|
|
868
|
+
discardedHash: state.diskHash,
|
|
869
|
+
message: `Discarded the manual edit to ${doc.path}. The text is recoverable from the activity event.`,
|
|
870
|
+
};
|
|
871
|
+
}
|