@jamesaphoenix/tx-core 0.11.1 → 0.12.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/README.md +105 -360
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/doc-service-impl.d.ts +2 -2
- package/dist/internal/doc-service-impl.d.ts.map +1 -1
- package/dist/internal/doc-service-impl.js +340 -268
- package/dist/internal/doc-service-impl.js.map +1 -1
- package/dist/internal/spec-trace-service-impl.d.ts +7 -0
- package/dist/internal/spec-trace-service-impl.d.ts.map +1 -1
- package/dist/internal/spec-trace-service-impl.js +25 -0
- package/dist/internal/spec-trace-service-impl.js.map +1 -1
- package/dist/internal/sync/service-impl.d.ts.map +1 -1
- package/dist/internal/sync/service-impl.js +20 -1
- package/dist/internal/sync/service-impl.js.map +1 -1
- package/dist/mappers/task.js +4 -4
- package/dist/mappers/task.js.map +1 -1
- package/dist/migrations-embedded.d.ts.map +1 -1
- package/dist/migrations-embedded.js +11 -1
- package/dist/migrations-embedded.js.map +1 -1
- package/dist/repo/claim-repo.d.ts +6 -0
- package/dist/repo/claim-repo.d.ts.map +1 -1
- package/dist/repo/claim-repo.js +51 -0
- package/dist/repo/claim-repo.js.map +1 -1
- package/dist/repo/task-repo/read.d.ts.map +1 -1
- package/dist/repo/task-repo/read.js +6 -5
- package/dist/repo/task-repo/read.js.map +1 -1
- package/dist/schemas/sync.d.ts +10 -10
- package/dist/schemas/sync.d.ts.map +1 -1
- package/dist/services/claim-service.d.ts +5 -0
- package/dist/services/claim-service.d.ts.map +1 -1
- package/dist/services/claim-service.js +3 -0
- package/dist/services/claim-service.js.map +1 -1
- package/dist/services/ready-service.d.ts.map +1 -1
- package/dist/services/ready-service.js +48 -7
- package/dist/services/ready-service.js.map +1 -1
- package/dist/services/swarm-verification.d.ts +1 -1
- package/dist/services/swarm-verification.js +1 -1
- package/dist/services/task-service/internals.d.ts +18 -1
- package/dist/services/task-service/internals.d.ts.map +1 -1
- package/dist/services/task-service/internals.js +61 -2
- package/dist/services/task-service/internals.js.map +1 -1
- package/dist/services/task-service.d.ts.map +1 -1
- package/dist/services/task-service.js +12 -5
- package/dist/services/task-service.js.map +1 -1
- package/dist/sync/claude-task-writer.js +1 -1
- package/dist/sync/claude-task-writer.js.map +1 -1
- package/dist/utils/doc-renderer.d.ts.map +1 -1
- package/dist/utils/doc-renderer.js +306 -61
- package/dist/utils/doc-renderer.js.map +1 -1
- package/dist/utils/ears-validator.d.ts.map +1 -1
- package/dist/utils/ears-validator.js +128 -14
- package/dist/utils/ears-validator.js.map +1 -1
- package/dist/utils/md-doc-parser.d.ts +15 -0
- package/dist/utils/md-doc-parser.d.ts.map +1 -0
- package/dist/utils/md-doc-parser.js +275 -0
- package/dist/utils/md-doc-parser.js.map +1 -0
- package/dist/utils/toml-config.d.ts +34 -0
- package/dist/utils/toml-config.d.ts.map +1 -1
- package/dist/utils/toml-config.js +177 -6
- package/dist/utils/toml-config.js.map +1 -1
- package/migrations/001_initial.sql +2 -1
- package/migrations/039_needs_review_status.sql +108 -0
- package/migrations/040_cycles.sql +33 -0
- package/package.json +1 -1
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DocService — business logic for docs-as-primitives (DD-023).
|
|
3
3
|
*
|
|
4
|
-
* Manages doc lifecycle (create/update/lock/version),
|
|
4
|
+
* Manages doc lifecycle (create/update/lock/version), markdown validation,
|
|
5
5
|
* linking (doc-doc, task-doc), invariant sync, drift detection, and graph data.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Markdown content lives on disk (specs/); DB stores metadata + links only.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, } from "node:fs";
|
|
10
10
|
import { resolve, dirname, join } from "node:path";
|
|
11
|
-
import { Cause, Context, Effect, Layer, Option } from "effect";
|
|
11
|
+
import { Cause, Context, Effect, Either, Layer, Option, Schema } from "effect";
|
|
12
12
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
13
13
|
import { DocRepository } from "../repo/doc-repo.js";
|
|
14
14
|
import { ValidationError, DocNotFoundError, DocLockedError, InvalidDocYamlError, InvariantNotFoundError, } from "../errors.js";
|
|
15
15
|
import { computeDocHash } from "../utils/doc-hash.js";
|
|
16
|
-
import {
|
|
16
|
+
import { renderIndexToMarkdown } from "../utils/doc-renderer.js";
|
|
17
17
|
import { formatEarsValidationErrors, validateEarsRequirements, } from "../utils/ears-validator.js";
|
|
18
|
+
import { parseMdDocSync, MdDocParseError } from "../utils/md-doc-parser.js";
|
|
18
19
|
import { readTxConfig } from "../utils/toml-config.js";
|
|
19
20
|
import { resolvePathWithin } from "../utils/file-path.js";
|
|
20
|
-
import { DOC_KINDS,
|
|
21
|
+
import { DOC_KINDS, DOC_CONTENT_SCHEMAS, EARS_PATTERNS, renderEarsRule, } from "@jamesaphoenix/tx-types";
|
|
21
22
|
// Local string arrays for .includes() (avoids readonly cast)
|
|
22
23
|
const docKindStrings = DOC_KINDS;
|
|
23
|
-
const enforcementStrings = INVARIANT_ENFORCEMENT_TYPES;
|
|
24
24
|
/** Infer link type from doc kinds (from → to). */
|
|
25
25
|
const inferLinkType = (fromKind, toKind) => {
|
|
26
26
|
if (fromKind === "overview" && toKind === "prd")
|
|
@@ -44,15 +44,15 @@ const kindSubdir = (kind) => {
|
|
|
44
44
|
if (kind === "overview")
|
|
45
45
|
return "";
|
|
46
46
|
if (kind === "requirement")
|
|
47
|
-
return "
|
|
47
|
+
return "requirements";
|
|
48
48
|
if (kind === "system_design")
|
|
49
|
-
return "
|
|
49
|
+
return "system-design";
|
|
50
50
|
return kind;
|
|
51
51
|
};
|
|
52
|
-
/** Resolve the
|
|
53
|
-
const
|
|
52
|
+
/** Resolve the markdown file path for a doc. */
|
|
53
|
+
const resolveDocPath = (docsPath, kind, name) => {
|
|
54
54
|
const sub = kindSubdir(kind);
|
|
55
|
-
const relativeDocPath = sub ? join(sub, `${name}.
|
|
55
|
+
const relativeDocPath = sub ? join(sub, `${name}.md`) : `${name}.md`;
|
|
56
56
|
const resolvedDocPath = resolvePathWithin(docsPath, relativeDocPath, {
|
|
57
57
|
useRealpath: true,
|
|
58
58
|
});
|
|
@@ -63,19 +63,14 @@ const resolveYamlPath = (docsPath, kind, name) => {
|
|
|
63
63
|
}
|
|
64
64
|
return resolvedDocPath;
|
|
65
65
|
};
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
useRealpath: true,
|
|
72
|
-
});
|
|
73
|
-
if (!resolvedDocPath) {
|
|
74
|
-
throw new ValidationError({
|
|
75
|
-
reason: `Invalid doc path for name '${name}'`,
|
|
66
|
+
const parseSpecTypeAsDocKind = (name, specType) => {
|
|
67
|
+
if (!docKindStrings.includes(specType)) {
|
|
68
|
+
throw new InvalidDocYamlError({
|
|
69
|
+
name,
|
|
70
|
+
reason: `Unsupported spec_type '${specType}' for docs service.`,
|
|
76
71
|
});
|
|
77
72
|
}
|
|
78
|
-
return
|
|
73
|
+
return specType;
|
|
79
74
|
};
|
|
80
75
|
const collectLegacyRequirements = (value) => {
|
|
81
76
|
const normalize = (item) => {
|
|
@@ -112,8 +107,123 @@ const collectLegacyRequirements = (value) => {
|
|
|
112
107
|
};
|
|
113
108
|
/** EARS is a hard requirement for PRDs — not configurable. */
|
|
114
109
|
const isEarsRequiredForLegacyPrds = () => true;
|
|
110
|
+
const nullableYamlStringKeys = new Set([
|
|
111
|
+
"name",
|
|
112
|
+
"title",
|
|
113
|
+
"problem_definition",
|
|
114
|
+
"subsystems",
|
|
115
|
+
"object_model",
|
|
116
|
+
"user_specific_content",
|
|
117
|
+
"problem",
|
|
118
|
+
"solution",
|
|
119
|
+
"overview",
|
|
120
|
+
"scope",
|
|
121
|
+
"design",
|
|
122
|
+
"architecture",
|
|
123
|
+
"data_model",
|
|
124
|
+
"open_questions",
|
|
125
|
+
"functional_requirements",
|
|
126
|
+
"id",
|
|
127
|
+
"statement",
|
|
128
|
+
"category",
|
|
129
|
+
"rationale",
|
|
130
|
+
"condition",
|
|
131
|
+
"impact",
|
|
132
|
+
"handling",
|
|
133
|
+
"expected_behavior",
|
|
134
|
+
"description",
|
|
135
|
+
"actor",
|
|
136
|
+
"trigger",
|
|
137
|
+
"outcome",
|
|
138
|
+
"target",
|
|
139
|
+
"reason",
|
|
140
|
+
"decision",
|
|
141
|
+
"consequence",
|
|
142
|
+
"requirement_id",
|
|
143
|
+
"verification",
|
|
144
|
+
"success_criteria",
|
|
145
|
+
"system",
|
|
146
|
+
"response",
|
|
147
|
+
"feature",
|
|
148
|
+
"state",
|
|
149
|
+
"test_hint",
|
|
150
|
+
"constraints",
|
|
151
|
+
"acceptance_criteria",
|
|
152
|
+
"non_goals",
|
|
153
|
+
"requirements",
|
|
154
|
+
"out_of_scope",
|
|
155
|
+
"goals",
|
|
156
|
+
"non_functional_requirements",
|
|
157
|
+
]);
|
|
158
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
const normalizeNullYamlStrings = (value, currentKey) => {
|
|
160
|
+
if (value === null) {
|
|
161
|
+
return currentKey && nullableYamlStringKeys.has(currentKey) ? "" : value;
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return value.map((item) => normalizeNullYamlStrings(item, currentKey));
|
|
165
|
+
}
|
|
166
|
+
if (!isRecord(value)) {
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
const normalized = {};
|
|
170
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
171
|
+
normalized[key] = normalizeNullYamlStrings(entry, key);
|
|
172
|
+
}
|
|
173
|
+
return normalized;
|
|
174
|
+
};
|
|
175
|
+
const normalizeLegacyEarsRequirements = (parsed) => {
|
|
176
|
+
const raw = parsed.ears_requirements;
|
|
177
|
+
if (!Array.isArray(raw))
|
|
178
|
+
return;
|
|
179
|
+
for (const entry of raw) {
|
|
180
|
+
if (!isRecord(entry))
|
|
181
|
+
continue;
|
|
182
|
+
if (typeof entry.statement === "string" && entry.statement.trim().length > 0) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const patternValue = entry.pattern;
|
|
186
|
+
const systemValue = entry.system;
|
|
187
|
+
const responseValue = entry.response;
|
|
188
|
+
if (typeof patternValue !== "string" ||
|
|
189
|
+
!EARS_PATTERNS.includes(patternValue) ||
|
|
190
|
+
typeof systemValue !== "string" ||
|
|
191
|
+
typeof responseValue !== "string") {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
entry.statement = renderEarsRule({
|
|
195
|
+
pattern: patternValue,
|
|
196
|
+
system: systemValue,
|
|
197
|
+
response: responseValue,
|
|
198
|
+
trigger: typeof entry.trigger === "string" ? entry.trigger : undefined,
|
|
199
|
+
state: typeof entry.state === "string" ? entry.state : undefined,
|
|
200
|
+
condition: typeof entry.condition === "string" ? entry.condition : undefined,
|
|
201
|
+
feature: typeof entry.feature === "string" ? entry.feature : undefined,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const normalizeForSchemaValidation = (kind, parsed) => {
|
|
206
|
+
const normalized = normalizeNullYamlStrings(parsed);
|
|
207
|
+
if (kind === "prd" || kind === "requirement") {
|
|
208
|
+
normalizeLegacyEarsRequirements(normalized);
|
|
209
|
+
}
|
|
210
|
+
return normalized;
|
|
211
|
+
};
|
|
212
|
+
const collectSchemaWarnings = (kind, parsed) => {
|
|
213
|
+
if (kind !== "prd")
|
|
214
|
+
return [];
|
|
215
|
+
const warnings = [];
|
|
216
|
+
if (parsed.requirements !== undefined) {
|
|
217
|
+
warnings.push("Deprecated field 'requirements' detected in PRD YAML; use 'ears_requirements' instead.");
|
|
218
|
+
}
|
|
219
|
+
if (parsed.out_of_scope !== undefined) {
|
|
220
|
+
warnings.push("Deprecated field 'out_of_scope' detected in PRD YAML; use 'non_goals' instead.");
|
|
221
|
+
}
|
|
222
|
+
return warnings;
|
|
223
|
+
};
|
|
115
224
|
/** Validate YAML content and return parsed object. */
|
|
116
|
-
const
|
|
225
|
+
const _validateYaml = (name, content, expectedKind, options) => {
|
|
226
|
+
const enforceContentSchema = options?.enforceContentSchema === true;
|
|
117
227
|
let parsed;
|
|
118
228
|
try {
|
|
119
229
|
parsed = parseYaml(content);
|
|
@@ -135,6 +245,24 @@ const validateYaml = (name, content, expectedKind) => {
|
|
|
135
245
|
? parsedObject.kind
|
|
136
246
|
: null;
|
|
137
247
|
const effectiveKind = expectedKind ?? yamlKind;
|
|
248
|
+
const warnings = collectSchemaWarnings(effectiveKind, parsedObject);
|
|
249
|
+
if (enforceContentSchema && effectiveKind && effectiveKind in DOC_CONTENT_SCHEMAS) {
|
|
250
|
+
const contentSchema = DOC_CONTENT_SCHEMAS[effectiveKind];
|
|
251
|
+
const normalizedForDecode = normalizeForSchemaValidation(effectiveKind, parsedObject);
|
|
252
|
+
const decoded = Schema.decodeUnknownEither(contentSchema)(normalizedForDecode);
|
|
253
|
+
if (Either.isLeft(decoded)) {
|
|
254
|
+
const detail = typeof decoded.left === "object" &&
|
|
255
|
+
decoded.left !== null &&
|
|
256
|
+
"message" in decoded.left &&
|
|
257
|
+
typeof decoded.left.message === "string"
|
|
258
|
+
? decoded.left.message
|
|
259
|
+
: String(decoded.left);
|
|
260
|
+
throw new InvalidDocYamlError({
|
|
261
|
+
name,
|
|
262
|
+
reason: `YAML schema validation failed for kind '${effectiveKind}': ${detail}`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
138
266
|
if (effectiveKind === "prd" && parsedObject.ears_requirements !== undefined) {
|
|
139
267
|
if (!Array.isArray(parsedObject.ears_requirements)) {
|
|
140
268
|
throw new InvalidDocYamlError({
|
|
@@ -162,10 +290,10 @@ const validateYaml = (name, content, expectedKind) => {
|
|
|
162
290
|
"'ears_requirements' array. EARS-structured requirements are mandatory for all PRDs.",
|
|
163
291
|
});
|
|
164
292
|
}
|
|
165
|
-
return parsedObject;
|
|
293
|
+
return { parsed: parsedObject, warnings };
|
|
166
294
|
};
|
|
167
295
|
/** Validate doc kind from YAML. */
|
|
168
|
-
const
|
|
296
|
+
const _validateKind = (name, parsed, expectedKind) => {
|
|
169
297
|
const yamlKind = parsed.kind;
|
|
170
298
|
if (yamlKind && typeof yamlKind === "string" && yamlKind !== expectedKind) {
|
|
171
299
|
throw new InvalidDocYamlError({
|
|
@@ -174,151 +302,54 @@ const validateKind = (name, parsed, expectedKind) => {
|
|
|
174
302
|
});
|
|
175
303
|
}
|
|
176
304
|
};
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
.toUpperCase()
|
|
180
|
-
.replace(/[^A-Z0-9]+/g, "-")
|
|
181
|
-
.replace(/^-+|-+$/g, "")
|
|
182
|
-
.replace(/-+/g, "-");
|
|
183
|
-
return normalized.length > 0 ? normalized : "DOC";
|
|
184
|
-
};
|
|
185
|
-
const collectStringList = (value) => {
|
|
186
|
-
return collectLegacyRequirements(value);
|
|
187
|
-
};
|
|
188
|
-
const extractSubsystemFromEarsId = (id) => {
|
|
189
|
-
const match = /^EARS-([A-Z0-9]+)-\d{3}$/.exec(id);
|
|
190
|
-
return match?.[1]?.toLowerCase();
|
|
191
|
-
};
|
|
192
|
-
const extractExplicitInvariants = (raw) => {
|
|
193
|
-
if (!Array.isArray(raw))
|
|
194
|
-
return [];
|
|
195
|
-
const candidates = [];
|
|
196
|
-
for (const item of raw) {
|
|
197
|
-
if (typeof item !== "object" || item === null)
|
|
198
|
-
continue;
|
|
199
|
-
const inv = item;
|
|
200
|
-
const id = typeof inv.id === "string" ? inv.id : null;
|
|
201
|
-
const rule = typeof inv.rule === "string" ? inv.rule : null;
|
|
202
|
-
const enforcement = typeof inv.enforcement === "string" ? inv.enforcement : null;
|
|
203
|
-
if (!id || !rule || !enforcement)
|
|
204
|
-
continue;
|
|
205
|
-
if (!enforcementStrings.includes(enforcement))
|
|
206
|
-
continue;
|
|
207
|
-
candidates.push({
|
|
208
|
-
id,
|
|
209
|
-
rule,
|
|
210
|
-
enforcement,
|
|
211
|
-
subsystem: typeof inv.subsystem === "string"
|
|
212
|
-
? inv.subsystem
|
|
213
|
-
: inv.subsystem === null
|
|
214
|
-
? null
|
|
215
|
-
: undefined,
|
|
216
|
-
testRef: typeof inv.test_ref === "string" ? inv.test_ref : undefined,
|
|
217
|
-
lintRule: typeof inv.lint_rule === "string" ? inv.lint_rule : undefined,
|
|
218
|
-
promptRef: typeof inv.prompt_ref === "string" ? inv.prompt_ref : undefined,
|
|
219
|
-
// Provenance
|
|
220
|
-
source: typeof inv.source === "string" ? inv.source : undefined,
|
|
221
|
-
sourceRef: typeof inv.source_ref === "string" ? inv.source_ref : undefined,
|
|
222
|
-
// EARS fields (explicit invariants may include these)
|
|
223
|
-
pattern: typeof inv.pattern === "string" ? inv.pattern : undefined,
|
|
224
|
-
triggerText: typeof inv.trigger === "string" ? inv.trigger : undefined,
|
|
225
|
-
stateText: typeof inv.state === "string" ? inv.state : undefined,
|
|
226
|
-
conditionText: typeof inv.condition === "string" ? inv.condition : undefined,
|
|
227
|
-
feature: typeof inv.feature === "string" ? inv.feature : undefined,
|
|
228
|
-
systemName: typeof inv.system === "string" ? inv.system : undefined,
|
|
229
|
-
response: typeof inv.response === "string" ? inv.response : undefined,
|
|
230
|
-
rationale: typeof inv.rationale === "string" ? inv.rationale : undefined,
|
|
231
|
-
testHint: typeof inv.test_hint === "string" ? inv.test_hint : undefined,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
return candidates;
|
|
305
|
+
const mapInvariantSeverityToEnforcement = (_severity) => {
|
|
306
|
+
return "integration_test";
|
|
235
307
|
};
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (!earsId || !system || !response)
|
|
251
|
-
continue;
|
|
252
|
-
const pattern = typeof req.pattern === "string" ? req.pattern : "ubiquitous";
|
|
253
|
-
const trigger = typeof req.trigger === "string" ? req.trigger : undefined;
|
|
254
|
-
const state = typeof req.state === "string" ? req.state : undefined;
|
|
255
|
-
const condition = typeof req.condition === "string" ? req.condition : undefined;
|
|
256
|
-
const feature = typeof req.feature === "string" ? req.feature : undefined;
|
|
257
|
-
const rationale = typeof req.rationale === "string" ? req.rationale : undefined;
|
|
258
|
-
const testHint = typeof req.test_hint === "string" ? req.test_hint : undefined;
|
|
259
|
-
// Validate pattern against known EARS patterns
|
|
260
|
-
const earsPatternStrings = EARS_PATTERNS;
|
|
261
|
-
const validPattern = earsPatternStrings.includes(pattern)
|
|
262
|
-
? pattern
|
|
263
|
-
: "ubiquitous";
|
|
264
|
-
const rule = renderEarsRule({
|
|
265
|
-
pattern: validPattern,
|
|
266
|
-
system,
|
|
267
|
-
response,
|
|
268
|
-
trigger,
|
|
269
|
-
state,
|
|
270
|
-
condition,
|
|
271
|
-
feature,
|
|
272
|
-
});
|
|
273
|
-
candidates.push({
|
|
274
|
-
id: `INV-${earsId}`,
|
|
275
|
-
rule,
|
|
276
|
-
enforcement: "integration_test",
|
|
277
|
-
subsystem: extractSubsystemFromEarsId(earsId),
|
|
278
|
-
testRef: testHint ?? null,
|
|
279
|
-
source: "explicit",
|
|
280
|
-
// EARS fields
|
|
281
|
-
pattern: validPattern,
|
|
282
|
-
triggerText: trigger ?? null,
|
|
283
|
-
stateText: state ?? null,
|
|
284
|
-
conditionText: condition ?? null,
|
|
285
|
-
feature: feature ?? null,
|
|
286
|
-
systemName: system,
|
|
287
|
-
response,
|
|
288
|
-
rationale: rationale ?? null,
|
|
289
|
-
testHint: testHint ?? null,
|
|
290
|
-
});
|
|
308
|
+
const mapMdEarsKindToLegacyPattern = (kind) => {
|
|
309
|
+
switch (kind) {
|
|
310
|
+
case "event-driven":
|
|
311
|
+
return "event_driven";
|
|
312
|
+
case "state-driven":
|
|
313
|
+
return "state_driven";
|
|
314
|
+
case "unwanted":
|
|
315
|
+
return "unwanted";
|
|
316
|
+
case "optional":
|
|
317
|
+
return "optional";
|
|
318
|
+
case "complex":
|
|
319
|
+
return "complex";
|
|
320
|
+
default:
|
|
321
|
+
return "ubiquitous";
|
|
291
322
|
}
|
|
292
|
-
return candidates;
|
|
293
323
|
};
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
324
|
+
const deriveEmbeddedInvariantCandidates = (doc, rawInvariants) => {
|
|
325
|
+
if (!rawInvariants || rawInvariants.length === 0)
|
|
296
326
|
return [];
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
rule,
|
|
304
|
-
enforcement: "integration_test",
|
|
305
|
-
subsystem: "prd",
|
|
327
|
+
return rawInvariants.map((inv) => ({
|
|
328
|
+
id: inv.id,
|
|
329
|
+
rule: inv.statement,
|
|
330
|
+
enforcement: mapInvariantSeverityToEnforcement(inv.severity),
|
|
331
|
+
subsystem: doc.kind,
|
|
332
|
+
testRef: inv.verified_by[0] ?? null,
|
|
306
333
|
source: "explicit",
|
|
334
|
+
sourceRef: "blocks.invariants",
|
|
307
335
|
}));
|
|
308
336
|
};
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
337
|
+
const deriveEmbeddedEarsInvariantCandidates = (doc, rawRequirements) => {
|
|
338
|
+
if (!rawRequirements || rawRequirements.length === 0)
|
|
311
339
|
return [];
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const docSegment = normalizeInvariantSegment(doc.name);
|
|
316
|
-
return goals.map((rule, index) => ({
|
|
317
|
-
id: `INV-DESIGN-${docSegment}-GOAL-${String(index + 1).padStart(3, "0")}`,
|
|
318
|
-
rule,
|
|
340
|
+
return rawRequirements.map((req) => ({
|
|
341
|
+
id: `INV-${req.id}`,
|
|
342
|
+
rule: req.statement,
|
|
319
343
|
enforcement: "integration_test",
|
|
320
|
-
subsystem:
|
|
321
|
-
source: "
|
|
344
|
+
subsystem: doc.kind,
|
|
345
|
+
source: "explicit",
|
|
346
|
+
sourceRef: "blocks.ears_requirements",
|
|
347
|
+
pattern: mapMdEarsKindToLegacyPattern(req.kind),
|
|
348
|
+
triggerText: req.when ?? null,
|
|
349
|
+
stateText: req.while ?? null,
|
|
350
|
+
conditionText: req.if ?? null,
|
|
351
|
+
feature: req.where ?? null,
|
|
352
|
+
rationale: req.rationale ?? null,
|
|
322
353
|
}));
|
|
323
354
|
};
|
|
324
355
|
export class DocService extends Context.Tag("DocService")() {
|
|
@@ -335,19 +366,47 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
335
366
|
mkdirSync(dir, { recursive: true });
|
|
336
367
|
}
|
|
337
368
|
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
369
|
+
const parseMarkdownSpecDocContent = (name, content) => {
|
|
370
|
+
const parsedResult = parseMdDocSync(content);
|
|
371
|
+
if (Either.isLeft(parsedResult)) {
|
|
372
|
+
const reason = parsedResult.left instanceof MdDocParseError
|
|
373
|
+
? parsedResult.left.reason
|
|
374
|
+
: String(parsedResult.left);
|
|
375
|
+
throw new InvalidDocYamlError({
|
|
376
|
+
name,
|
|
377
|
+
reason: `Markdown validation failed: ${reason}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (parsedResult.right.kind !== "spec") {
|
|
381
|
+
throw new InvalidDocYamlError({
|
|
382
|
+
name,
|
|
383
|
+
reason: "Markdown docs managed by DocService must have frontmatter kind: spec.",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return parsedResult.right;
|
|
387
|
+
};
|
|
388
|
+
/** Validate a single markdown doc and return its canonical file path. */
|
|
389
|
+
const validateDocFile = (doc, docsPath) => {
|
|
390
|
+
const docPath = resolveDocPath(docsPath, doc.kind, doc.name);
|
|
391
|
+
if (!existsSync(docPath)) {
|
|
342
392
|
throw new DocNotFoundError({ name: doc.name });
|
|
343
393
|
}
|
|
344
|
-
const
|
|
345
|
-
const parsed =
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
394
|
+
const content = readFileSync(docPath, "utf8");
|
|
395
|
+
const parsed = parseMarkdownSpecDocContent(doc.name, content);
|
|
396
|
+
const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type);
|
|
397
|
+
if (parsed.frontmatter.name !== doc.name) {
|
|
398
|
+
throw new InvalidDocYamlError({
|
|
399
|
+
name: doc.name,
|
|
400
|
+
reason: `Frontmatter name '${parsed.frontmatter.name}' does not match doc name '${doc.name}'.`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (parsedKind !== doc.kind) {
|
|
404
|
+
throw new InvalidDocYamlError({
|
|
405
|
+
name: doc.name,
|
|
406
|
+
reason: `Frontmatter spec_type '${parsed.frontmatter.spec_type}' does not match doc kind '${doc.kind}'.`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return docPath;
|
|
351
410
|
};
|
|
352
411
|
/** Generate index.yml and index.md from all docs in DB. */
|
|
353
412
|
function generateIndexEffect(docsPath) {
|
|
@@ -463,22 +522,31 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
463
522
|
writeFileSync(indexMdPath, indexMd, "utf8");
|
|
464
523
|
});
|
|
465
524
|
}
|
|
466
|
-
/** Sync invariants from a single doc
|
|
525
|
+
/** Sync invariants from a single markdown doc into DB. */
|
|
467
526
|
function syncInvariantsForDoc(doc) {
|
|
468
527
|
return Effect.gen(function* () {
|
|
469
528
|
const docsPath = getDocsPath();
|
|
470
|
-
const
|
|
471
|
-
if (!existsSync(
|
|
529
|
+
const docPath = resolveDocPath(docsPath, doc.kind, doc.name);
|
|
530
|
+
if (!existsSync(docPath)) {
|
|
472
531
|
return [];
|
|
473
532
|
}
|
|
474
|
-
const
|
|
475
|
-
const parsed =
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
533
|
+
const content = readFileSync(docPath, "utf8");
|
|
534
|
+
const parsed = parseMarkdownSpecDocContent(doc.name, content);
|
|
535
|
+
const parsedKind = parseSpecTypeAsDocKind(doc.name, parsed.frontmatter.spec_type);
|
|
536
|
+
if (parsed.frontmatter.name !== doc.name) {
|
|
537
|
+
throw new InvalidDocYamlError({
|
|
538
|
+
name: doc.name,
|
|
539
|
+
reason: `Frontmatter name '${parsed.frontmatter.name}' does not match doc name '${doc.name}'.`,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (parsedKind !== doc.kind) {
|
|
543
|
+
throw new InvalidDocYamlError({
|
|
544
|
+
name: doc.name,
|
|
545
|
+
reason: `Frontmatter spec_type '${parsed.frontmatter.spec_type}' does not match doc kind '${doc.kind}'.`,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const explicit = deriveEmbeddedInvariantCandidates(doc, parsed.blocks.invariants);
|
|
549
|
+
const derived = deriveEmbeddedEarsInvariantCandidates(doc, parsed.blocks.ears_requirements);
|
|
482
550
|
const candidates = [];
|
|
483
551
|
const seen = new Set();
|
|
484
552
|
for (const candidate of [...explicit, ...derived]) {
|
|
@@ -526,7 +594,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
526
594
|
}
|
|
527
595
|
return {
|
|
528
596
|
create: (input) => Effect.gen(function* () {
|
|
529
|
-
const { kind, name, title,
|
|
597
|
+
const { kind, name, title, content, metadata } = input;
|
|
530
598
|
if (!docKindStrings.includes(kind)) {
|
|
531
599
|
return yield* Effect.fail(new ValidationError({ reason: `Invalid doc kind: ${kind}` }));
|
|
532
600
|
}
|
|
@@ -535,37 +603,49 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
535
603
|
reason: `Invalid doc name: ${name}. Use alphanumeric with dashes/dots.`,
|
|
536
604
|
}));
|
|
537
605
|
}
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
const
|
|
606
|
+
const parsedDoc = parseMarkdownSpecDocContent(name, content);
|
|
607
|
+
const frontmatter = parsedDoc.frontmatter;
|
|
608
|
+
const parsedKind = parseSpecTypeAsDocKind(name, frontmatter.spec_type);
|
|
609
|
+
if (frontmatter.name !== name) {
|
|
610
|
+
return yield* Effect.fail(new ValidationError({
|
|
611
|
+
reason: `Frontmatter name '${frontmatter.name}' does not match input name '${name}'.`,
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
if (frontmatter.title !== title) {
|
|
615
|
+
return yield* Effect.fail(new ValidationError({
|
|
616
|
+
reason: `Frontmatter title '${frontmatter.title}' does not match input title '${title}'.`,
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
if (parsedKind !== kind) {
|
|
620
|
+
return yield* Effect.fail(new ValidationError({
|
|
621
|
+
reason: `Frontmatter spec_type '${frontmatter.spec_type}' does not match input kind '${kind}'.`,
|
|
622
|
+
}));
|
|
623
|
+
}
|
|
624
|
+
const existing = yield* docRepo.findByName(frontmatter.name);
|
|
541
625
|
if (existing) {
|
|
542
626
|
return yield* Effect.fail(new ValidationError({
|
|
543
|
-
reason: `Doc '${name}' already exists (v${existing.version})`,
|
|
627
|
+
reason: `Doc '${frontmatter.name}' already exists (v${existing.version})`,
|
|
544
628
|
}));
|
|
545
629
|
}
|
|
546
|
-
const hash = computeDocHash(
|
|
630
|
+
const hash = computeDocHash(content);
|
|
547
631
|
const docsPath = getDocsPath();
|
|
548
|
-
const filePath =
|
|
632
|
+
const filePath = resolveDocPath(docsPath, parsedKind, frontmatter.name);
|
|
549
633
|
ensureDir(filePath);
|
|
550
|
-
writeFileSync(filePath,
|
|
551
|
-
const sub = kindSubdir(
|
|
552
|
-
const relPath = sub
|
|
634
|
+
writeFileSync(filePath, content, "utf8");
|
|
635
|
+
const sub = kindSubdir(parsedKind);
|
|
636
|
+
const relPath = sub
|
|
637
|
+
? join(sub, `${frontmatter.name}.md`)
|
|
638
|
+
: `${frontmatter.name}.md`;
|
|
553
639
|
const doc = yield* docRepo.insert({
|
|
554
640
|
hash,
|
|
555
|
-
kind,
|
|
556
|
-
name,
|
|
557
|
-
title,
|
|
641
|
+
kind: parsedKind,
|
|
642
|
+
name: frontmatter.name,
|
|
643
|
+
title: frontmatter.title,
|
|
558
644
|
version: 1,
|
|
559
645
|
filePath: relPath,
|
|
560
646
|
parentDocId: null,
|
|
561
647
|
metadata: metadata ? JSON.stringify(metadata) : undefined,
|
|
562
648
|
});
|
|
563
|
-
try {
|
|
564
|
-
renderSingleDoc(doc, docsPath);
|
|
565
|
-
}
|
|
566
|
-
catch {
|
|
567
|
-
/* non-fatal */
|
|
568
|
-
}
|
|
569
649
|
yield* generateIndexEffect(docsPath);
|
|
570
650
|
return doc;
|
|
571
651
|
}),
|
|
@@ -576,7 +656,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
576
656
|
}
|
|
577
657
|
return doc;
|
|
578
658
|
}),
|
|
579
|
-
update: (name,
|
|
659
|
+
update: (name, content) => Effect.gen(function* () {
|
|
580
660
|
const doc = yield* docRepo.findByName(name);
|
|
581
661
|
if (!doc) {
|
|
582
662
|
return yield* Effect.fail(new DocNotFoundError({ name }));
|
|
@@ -584,25 +664,32 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
584
664
|
if (doc.status === "locked") {
|
|
585
665
|
return yield* Effect.fail(new DocLockedError({ name, version: doc.version }));
|
|
586
666
|
}
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
const
|
|
667
|
+
const parsedDoc = parseMarkdownSpecDocContent(name, content);
|
|
668
|
+
const frontmatter = parsedDoc.frontmatter;
|
|
669
|
+
const parsedKind = parseSpecTypeAsDocKind(name, frontmatter.spec_type);
|
|
670
|
+
if (frontmatter.name !== name) {
|
|
671
|
+
return yield* Effect.fail(new InvalidDocYamlError({
|
|
672
|
+
name,
|
|
673
|
+
reason: `Frontmatter name '${frontmatter.name}' does not match doc '${name}'.`,
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
if (parsedKind !== doc.kind) {
|
|
677
|
+
return yield* Effect.fail(new InvalidDocYamlError({
|
|
678
|
+
name,
|
|
679
|
+
reason: `Frontmatter spec_type '${frontmatter.spec_type}' does not match existing kind '${doc.kind}'.`,
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
const hash = computeDocHash(content);
|
|
590
683
|
const docsPath = getDocsPath();
|
|
591
|
-
const filePath =
|
|
684
|
+
const filePath = resolveDocPath(docsPath, doc.kind, name);
|
|
592
685
|
ensureDir(filePath);
|
|
593
|
-
writeFileSync(filePath,
|
|
594
|
-
const title =
|
|
686
|
+
writeFileSync(filePath, content, "utf8");
|
|
687
|
+
const title = frontmatter.title;
|
|
595
688
|
yield* docRepo.update(doc.id, { hash, title });
|
|
596
689
|
const updated = yield* docRepo.findById(doc.id);
|
|
597
690
|
if (!updated) {
|
|
598
691
|
return yield* Effect.fail(new DocNotFoundError({ name }));
|
|
599
692
|
}
|
|
600
|
-
try {
|
|
601
|
-
renderSingleDoc(updated, docsPath);
|
|
602
|
-
}
|
|
603
|
-
catch {
|
|
604
|
-
/* non-fatal */
|
|
605
|
-
}
|
|
606
693
|
yield* generateIndexEffect(docsPath);
|
|
607
694
|
return updated;
|
|
608
695
|
}),
|
|
@@ -620,14 +707,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
620
707
|
if (!locked) {
|
|
621
708
|
return yield* Effect.fail(new DocNotFoundError({ name }));
|
|
622
709
|
}
|
|
623
|
-
|
|
624
|
-
try {
|
|
625
|
-
renderSingleDoc(locked, docsPath);
|
|
626
|
-
}
|
|
627
|
-
catch {
|
|
628
|
-
/* non-fatal */
|
|
629
|
-
}
|
|
630
|
-
yield* generateIndexEffect(docsPath);
|
|
710
|
+
yield* generateIndexEffect(getDocsPath());
|
|
631
711
|
return locked;
|
|
632
712
|
}),
|
|
633
713
|
list: (filter) => docRepo.findAll(filter),
|
|
@@ -641,18 +721,10 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
641
721
|
}
|
|
642
722
|
yield* docRepo.remove(doc.id);
|
|
643
723
|
const docsPath = getDocsPath();
|
|
644
|
-
const
|
|
645
|
-
const mdPath = resolveMdPath(docsPath, doc.kind, name);
|
|
724
|
+
const docPath = resolveDocPath(docsPath, doc.kind, name);
|
|
646
725
|
try {
|
|
647
|
-
if (existsSync(
|
|
648
|
-
unlinkSync(
|
|
649
|
-
}
|
|
650
|
-
catch {
|
|
651
|
-
/* non-fatal */
|
|
652
|
-
}
|
|
653
|
-
try {
|
|
654
|
-
if (existsSync(mdPath))
|
|
655
|
-
unlinkSync(mdPath);
|
|
726
|
+
if (existsSync(docPath))
|
|
727
|
+
unlinkSync(docPath);
|
|
656
728
|
}
|
|
657
729
|
catch {
|
|
658
730
|
/* non-fatal */
|
|
@@ -667,16 +739,16 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
667
739
|
if (!doc) {
|
|
668
740
|
return yield* Effect.fail(new DocNotFoundError({ name }));
|
|
669
741
|
}
|
|
670
|
-
rendered.push(
|
|
742
|
+
rendered.push(validateDocFile(doc, docsPath));
|
|
671
743
|
}
|
|
672
744
|
else {
|
|
673
745
|
const allDocs = yield* docRepo.findAll();
|
|
674
746
|
for (const doc of allDocs) {
|
|
675
747
|
try {
|
|
676
|
-
rendered.push(
|
|
748
|
+
rendered.push(validateDocFile(doc, docsPath));
|
|
677
749
|
}
|
|
678
750
|
catch {
|
|
679
|
-
/* skip docs with missing
|
|
751
|
+
/* skip docs with invalid or missing markdown */
|
|
680
752
|
}
|
|
681
753
|
}
|
|
682
754
|
}
|
|
@@ -694,17 +766,19 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
694
766
|
}));
|
|
695
767
|
}
|
|
696
768
|
const docsPath = getDocsPath();
|
|
697
|
-
const
|
|
698
|
-
if (!existsSync(
|
|
769
|
+
const docPath = resolveDocPath(docsPath, doc.kind, name);
|
|
770
|
+
if (!existsSync(docPath)) {
|
|
699
771
|
return yield* Effect.fail(new ValidationError({
|
|
700
|
-
reason: `
|
|
772
|
+
reason: `Markdown file not found for '${name}'`,
|
|
701
773
|
}));
|
|
702
774
|
}
|
|
703
|
-
const
|
|
704
|
-
const hash = computeDocHash(
|
|
775
|
+
const content = readFileSync(docPath, "utf8");
|
|
776
|
+
const hash = computeDocHash(content);
|
|
705
777
|
const newVersion = doc.version + 1;
|
|
706
778
|
const versionSub = kindSubdir(doc.kind);
|
|
707
|
-
const relPath = versionSub
|
|
779
|
+
const relPath = versionSub
|
|
780
|
+
? join(versionSub, `${name}.md`)
|
|
781
|
+
: `${name}.md`;
|
|
708
782
|
const newDoc = yield* docRepo.insert({
|
|
709
783
|
hash,
|
|
710
784
|
kind: doc.kind,
|
|
@@ -714,12 +788,6 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
714
788
|
filePath: relPath,
|
|
715
789
|
parentDocId: doc.id,
|
|
716
790
|
});
|
|
717
|
-
try {
|
|
718
|
-
renderSingleDoc(newDoc, docsPath);
|
|
719
|
-
}
|
|
720
|
-
catch {
|
|
721
|
-
/* non-fatal */
|
|
722
|
-
}
|
|
723
791
|
yield* generateIndexEffect(docsPath);
|
|
724
792
|
return newDoc;
|
|
725
793
|
}),
|
|
@@ -757,21 +825,31 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
757
825
|
reason: `Patches can only be created on design docs, got '${parentDoc.kind}'`,
|
|
758
826
|
}));
|
|
759
827
|
}
|
|
760
|
-
const
|
|
761
|
-
|
|
828
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
829
|
+
const patchFrontmatter = stringifyYaml({
|
|
830
|
+
kind: "spec",
|
|
831
|
+
spec_type: "design",
|
|
762
832
|
name: patchName,
|
|
763
833
|
title: patchTitle,
|
|
764
|
-
status: "
|
|
834
|
+
status: "draft",
|
|
765
835
|
version: 1,
|
|
836
|
+
owners: ["docs-team"],
|
|
837
|
+
summary: `Patch for ${parentDoc.name}: ${patchTitle}`,
|
|
838
|
+
domain: "docs",
|
|
839
|
+
tags: ["patch"],
|
|
840
|
+
depends_on: [parentDoc.name],
|
|
841
|
+
supersedes: [],
|
|
766
842
|
implements: parentDoc.name,
|
|
767
|
-
|
|
843
|
+
last_reviewed_at: today,
|
|
768
844
|
});
|
|
769
|
-
const
|
|
845
|
+
const patchContent = `---\n${patchFrontmatter}---\n\n# Summary\nPatch for ${parentDoc.name}: ${patchTitle}\n\n# Architecture\nPatch architecture details.\n\n# Interfaces\n\`\`\`yaml\ninterfaces: []\n\`\`\`\n\n# Data Model\nNo data model changes.\n\n# Invariants\n\`\`\`yaml\ninvariants: []\n\`\`\`\n\n# Failure Modes\n\`\`\`yaml\nfailure_modes: []\n\`\`\`\n\n# Verification\n\`\`\`yaml\nverification: []\n\`\`\`\n`;
|
|
846
|
+
parseMarkdownSpecDocContent(patchName, patchContent);
|
|
847
|
+
const hash = computeDocHash(patchContent);
|
|
770
848
|
const docsPath = getDocsPath();
|
|
771
|
-
const filePath =
|
|
849
|
+
const filePath = resolveDocPath(docsPath, "design", patchName);
|
|
772
850
|
ensureDir(filePath);
|
|
773
|
-
writeFileSync(filePath,
|
|
774
|
-
const relPath = join("design", `${patchName}.
|
|
851
|
+
writeFileSync(filePath, patchContent, "utf8");
|
|
852
|
+
const relPath = join("design", `${patchName}.md`);
|
|
775
853
|
const patchDoc = yield* docRepo.insert({
|
|
776
854
|
hash,
|
|
777
855
|
kind: "design",
|
|
@@ -782,12 +860,6 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
782
860
|
parentDocId: null,
|
|
783
861
|
});
|
|
784
862
|
yield* docRepo.createLink(patchDoc.id, parentDoc.id, "design_patch");
|
|
785
|
-
try {
|
|
786
|
-
renderSingleDoc(patchDoc, docsPath);
|
|
787
|
-
}
|
|
788
|
-
catch {
|
|
789
|
-
/* non-fatal */
|
|
790
|
-
}
|
|
791
863
|
yield* generateIndexEffect(docsPath);
|
|
792
864
|
return patchDoc;
|
|
793
865
|
}),
|
|
@@ -806,16 +878,16 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
806
878
|
}
|
|
807
879
|
const warnings = [];
|
|
808
880
|
const docsPath = getDocsPath();
|
|
809
|
-
const
|
|
810
|
-
if (existsSync(
|
|
811
|
-
const content = readFileSync(
|
|
881
|
+
const docPath = resolveDocPath(docsPath, doc.kind, name);
|
|
882
|
+
if (existsSync(docPath)) {
|
|
883
|
+
const content = readFileSync(docPath, "utf8");
|
|
812
884
|
const currentHash = computeDocHash(content);
|
|
813
885
|
if (currentHash !== doc.hash) {
|
|
814
886
|
warnings.push(`Content hash mismatch: DB has ${doc.hash.slice(0, 8)}..., file has ${currentHash.slice(0, 8)}...`);
|
|
815
887
|
}
|
|
816
888
|
}
|
|
817
889
|
else {
|
|
818
|
-
warnings.push(`
|
|
890
|
+
warnings.push(`Markdown file missing: ${docPath}`);
|
|
819
891
|
}
|
|
820
892
|
const taskLinks = yield* docRepo.getTaskLinksForDoc(doc.id);
|
|
821
893
|
if (taskLinks.length === 0 && doc.kind === "design") {
|
|
@@ -837,7 +909,7 @@ export const DocServiceLive = Layer.effect(DocService, Effect.gen(function* () {
|
|
|
837
909
|
else {
|
|
838
910
|
const allDocs = yield* docRepo.findAll();
|
|
839
911
|
for (const doc of allDocs) {
|
|
840
|
-
// Keep whole-repo sync resilient: one malformed
|
|
912
|
+
// Keep whole-repo sync resilient: one malformed markdown file should not
|
|
841
913
|
// prevent invariants from being refreshed for every other doc.
|
|
842
914
|
const result = yield* syncInvariantsForDoc(doc).pipe(Effect.catchAllCause((cause) => {
|
|
843
915
|
const defect = Cause.dieOption(cause);
|