create-evda 0.1.1 → 0.1.3
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 +48 -15
- package/bin/evda.mjs +3 -1
- package/create-evda.mjs +114 -31
- package/lib/agents.mjs +1 -1
- package/lib/doctor.mjs +52 -1
- package/lib/kinds.mjs +301 -0
- package/lib/kinds.schema.json +684 -0
- package/lib/setup.mjs +222 -0
- package/package.json +2 -2
- package/templates/AGENTS.md +277 -140
- package/templates/CLAUDE.md +1 -1
- package/templates/GEMINI.md +3 -0
- package/templates/README.md +24 -13
- package/templates/evda-digest.md +72 -32
package/lib/kinds.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// The six person-layer kinds (Spec 07, section 2): note, person-profile, course, piece, rule,
|
|
2
|
+
// hypothesis. A folder's `folder.evda.json` names its type; an entry in that folder is one of these.
|
|
3
|
+
//
|
|
4
|
+
// The shape of each kind is its SCHEMA in the reference vault, copied here as
|
|
5
|
+
// lib/kinds.schema.json by scripts/build-kinds.mjs (with a drift check), and read by `parseShape`
|
|
6
|
+
// the way the substrate's generated parsers read it: first problem wins, same path, same words.
|
|
7
|
+
// The cross-field rules are a second implementation of common/src/<kind>.ts, because this package
|
|
8
|
+
// imports nothing from the workspace. `common/src/person-kinds.vectors.json` is what the two agree
|
|
9
|
+
// about, and test/kinds.test.mjs runs every vector here and requires exactly its problems.
|
|
10
|
+
//
|
|
11
|
+
// Entry files: a `.json` or `.yaml` file holding one entity, or a markdown file whose frontmatter
|
|
12
|
+
// carries `kind:`. For a note in markdown, the body is the words when the frontmatter has none.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { parseYaml } from "./yaml.mjs";
|
|
16
|
+
|
|
17
|
+
const SCHEMAS = JSON.parse(readFileSync(new URL("./kinds.schema.json", import.meta.url), "utf8")).kinds;
|
|
18
|
+
|
|
19
|
+
export const PERSON_KINDS = Object.keys(SCHEMAS);
|
|
20
|
+
|
|
21
|
+
// folder.evda.json `type` -> entity kind. `record` is the Spec 07 name for the owner's words; the
|
|
22
|
+
// substrate's own `record` kind is a row of tabular data, so the entity is `note`.
|
|
23
|
+
export const KIND_FOR_TYPE = { record: "note", person: "person-profile", course: "course", piece: "piece", rule: "rule", hypothesis: "hypothesis" };
|
|
24
|
+
|
|
25
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
26
|
+
const absent = (v) => v === undefined || v === null;
|
|
27
|
+
const EXPECT = { string: "expected string", "iso-date": "expected string", enum: "expected string", number: "expected number", boolean: "expected boolean", object: "expected object", array: "expected array" };
|
|
28
|
+
|
|
29
|
+
class Shape extends Error {}
|
|
30
|
+
|
|
31
|
+
function field(value, f, path) {
|
|
32
|
+
switch (f.type) {
|
|
33
|
+
case "string":
|
|
34
|
+
case "iso-date":
|
|
35
|
+
if (typeof value !== "string") throw new Shape(`${path}: expected string`);
|
|
36
|
+
return value;
|
|
37
|
+
case "enum":
|
|
38
|
+
if (typeof value !== "string") throw new Shape(`${path}: expected string`);
|
|
39
|
+
if (!f.values.includes(value)) throw new Shape(`${path}: expected one of ${f.values.join("|")}`);
|
|
40
|
+
return value;
|
|
41
|
+
case "number":
|
|
42
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Shape(`${path}: expected number`);
|
|
43
|
+
return value;
|
|
44
|
+
case "boolean":
|
|
45
|
+
if (typeof value !== "boolean") throw new Shape(`${path}: expected boolean`);
|
|
46
|
+
return value;
|
|
47
|
+
case "array": {
|
|
48
|
+
if (!Array.isArray(value)) throw new Shape(`${path}: expected array`);
|
|
49
|
+
if (f.minItems !== undefined && value.length < f.minItems) throw new Shape(`${path}: expected at least ${f.minItems} items`);
|
|
50
|
+
if (f.maxItems !== undefined && value.length > f.maxItems) throw new Shape(`${path}: expected at most ${f.maxItems} items`);
|
|
51
|
+
return value.map((v, i) => field(v, f.items, `${path}[${i}]`));
|
|
52
|
+
}
|
|
53
|
+
case "object": {
|
|
54
|
+
if (!isObject(value)) throw new Shape(`${path}: expected object`);
|
|
55
|
+
if (!f.fields) return value;
|
|
56
|
+
return fields(value, f.fields, path, f.extras === "passthrough");
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
throw new Shape(`${path}: field type ${f.type} is not one this tool reads`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fields(root, decl, path, passthrough) {
|
|
64
|
+
const out = passthrough ? { ...root } : {};
|
|
65
|
+
for (const [name, f] of Object.entries(decl)) {
|
|
66
|
+
const v = root[name];
|
|
67
|
+
if (absent(v)) {
|
|
68
|
+
if (f.default !== undefined) out[name] = f.default;
|
|
69
|
+
else if (f.optional) delete out[name];
|
|
70
|
+
else throw new Shape(`${path}.${name}: ${EXPECT[f.type] ?? "expected a value"}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
out[name] = field(v, f, `${path}.${name}`);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The shape parser: returns { dto } or { problem }, as the generated parser would throw it.
|
|
79
|
+
export function parseShape(kind, raw) {
|
|
80
|
+
const schema = SCHEMAS[kind];
|
|
81
|
+
if (!schema) return { problem: `$.kind: "${kind}" is not a person-layer kind (${PERSON_KINDS.join(", ")})` };
|
|
82
|
+
try {
|
|
83
|
+
if (!isObject(raw)) throw new Shape("$: expected object");
|
|
84
|
+
const head = {};
|
|
85
|
+
if (typeof raw.id !== "string") throw new Shape("$.id: expected string");
|
|
86
|
+
head.id = raw.id;
|
|
87
|
+
if (typeof raw.kind !== "string") throw new Shape("$.kind: expected string");
|
|
88
|
+
if (raw.kind !== kind) throw new Shape(`$.kind: expected '${kind}'`);
|
|
89
|
+
head.kind = raw.kind;
|
|
90
|
+
if (typeof raw.schemaVersion !== "string") throw new Shape("$.schemaVersion: expected string");
|
|
91
|
+
head.schemaVersion = raw.schemaVersion;
|
|
92
|
+
return { dto: { ...head, ...fields(raw, schema.fields, "$", false) } };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e instanceof Shape) return { problem: e.message };
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- cross-field rules, mirroring common/src/person-kind-check.ts and common/src/<kind>.ts ----
|
|
100
|
+
|
|
101
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
102
|
+
export function isDay(value) {
|
|
103
|
+
if (typeof value !== "string") return false;
|
|
104
|
+
const m = DAY.exec(value);
|
|
105
|
+
if (!m) return false;
|
|
106
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
107
|
+
return d.getUTCFullYear() === Number(m[1]) && d.getUTCMonth() === Number(m[2]) - 1 && d.getUTCDate() === Number(m[3]);
|
|
108
|
+
}
|
|
109
|
+
const dayProblem = (path, v) => (isDay(v) ? null : `${path}: "${String(v)}" is not a day. Write YYYY-MM-DD.`);
|
|
110
|
+
const emptyProblem = (path, v, what) => (typeof v === "string" && v.trim() !== "" ? null : `${path}: is empty. ${what}`);
|
|
111
|
+
const hasText = (s) => typeof s === "string" && s.trim() !== "";
|
|
112
|
+
function duplicates(ids) {
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const twice = new Set();
|
|
115
|
+
for (const id of ids) (seen.has(id) ? twice : seen).add(id);
|
|
116
|
+
return [...twice];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const WORD_NUMBER = "one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|dozen|once|twice";
|
|
120
|
+
const NUMBER = `(?:\\d+(?:\\.\\d+)?|${WORD_NUMBER})`;
|
|
121
|
+
const PATTERNS = [
|
|
122
|
+
/\b(?:streak|streaks|in a row|consecutive)\b/i,
|
|
123
|
+
new RegExp(`\\b(?:at most|at least|no more than|no fewer than|no less than|more than|fewer than|less than|up to|maximum of|minimum of|limit of|cap of|budget of|threshold of)\\s+(?:a\\s+)?${NUMBER}\\b`, "i"),
|
|
124
|
+
new RegExp(`\\b${NUMBER}\\s+or\\s+(?:more|fewer|less)\\b`, "i"),
|
|
125
|
+
new RegExp(`\\b${NUMBER}\\b[^.;:]{0,30}?\\b(?:times?|days?|nights?|weeks?|months?|hours?|minutes?)\\b`, "i"),
|
|
126
|
+
new RegExp(`\\b${NUMBER}\\s+(?:in|out of)\\s+${NUMBER}\\b`, "i"),
|
|
127
|
+
new RegExp(`\\b(?:once|twice)\\s+(?:a|an|per|each|every)\\b`, "i"),
|
|
128
|
+
new RegExp(`\\b${NUMBER}\\s+(?:a|an|per)\\s+(?:day|night|week|month|year)\\b`, "i"),
|
|
129
|
+
/\d\s*%|\bpercent\b/i,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// The words in a rule's statement that set a threshold, a count or a streak, or null.
|
|
133
|
+
export function thresholdIn(statement) {
|
|
134
|
+
for (const p of PATTERNS) {
|
|
135
|
+
const m = p.exec(statement);
|
|
136
|
+
if (m) return m[0];
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
142
|
+
|
|
143
|
+
const CHECKS = {
|
|
144
|
+
note(dto, { owner }) {
|
|
145
|
+
const out = [];
|
|
146
|
+
const push = (p) => p && out.push(p);
|
|
147
|
+
push(dayProblem("$.date", dto.date));
|
|
148
|
+
push(emptyProblem("$.words", dto.words, "A note holds the owner's words, and an empty one holds nothing."));
|
|
149
|
+
if (dto.author !== owner) out.push(`$.author: "${dto.author}" is not the owner "${owner}". A note holds the owner's words only; anyone else's account belongs in their own record.`);
|
|
150
|
+
return out;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
"person-profile"(dto, { owner }) {
|
|
154
|
+
const out = [];
|
|
155
|
+
const push = (p) => p && out.push(p);
|
|
156
|
+
const obs = dto.observations;
|
|
157
|
+
const byId = new Map(obs.map((o) => [o.id, o]));
|
|
158
|
+
for (const id of duplicates(obs.map((o) => o.id))) out.push(`$.observations: id "${id}" is used more than once. supersededBy points at an id, so each must name one observation.`);
|
|
159
|
+
obs.forEach((o, i) => {
|
|
160
|
+
const at = `$.observations[${i}]`;
|
|
161
|
+
push(dayProblem(`${at}.since`, o.since));
|
|
162
|
+
push(emptyProblem(`${at}.text`, o.text, "An observation says something."));
|
|
163
|
+
if (o.supersededBy !== undefined) {
|
|
164
|
+
const next = byId.get(o.supersededBy);
|
|
165
|
+
if (o.supersededBy === o.id) out.push(`${at}.supersededBy: names itself. An observation is superseded by a later one.`);
|
|
166
|
+
else if (!next) out.push(`${at}.supersededBy: names "${o.supersededBy}", which is not an observation in this profile.`);
|
|
167
|
+
else if (next.since < o.since) out.push(`${at}.supersededBy: names "${next.id}", dated ${next.since}, which is before this one (${o.since}). A later observation supersedes an earlier one.`);
|
|
168
|
+
else {
|
|
169
|
+
const seen = new Set([o.id]);
|
|
170
|
+
let cur = next;
|
|
171
|
+
while (cur) {
|
|
172
|
+
if (seen.has(cur.id)) { out.push(`${at}.supersededBy: the chain from "${o.id}" loops back to "${cur.id}".`); break; }
|
|
173
|
+
seen.add(cur.id);
|
|
174
|
+
cur = cur.supersededBy !== undefined ? byId.get(cur.supersededBy) : undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (o.quote) {
|
|
179
|
+
push(emptyProblem(`${at}.quote.words`, o.quote.words, "A quote holds words."));
|
|
180
|
+
if (o.quote.said !== undefined) push(dayProblem(`${at}.quote.said`, o.quote.said));
|
|
181
|
+
if (o.quote.provenance === "owner" && o.quote.by !== owner) out.push(`${at}.quote: marked as the owner's words, but by is "${o.quote.by}", not the owner "${owner}". Words someone else produced are provenance agent.`);
|
|
182
|
+
if (o.quote.provenance === "agent" && o.quote.by === owner) out.push(`${at}.quote: marked as an agent's summary, but by is the owner "${owner}". The owner's own words are provenance owner.`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
return out;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
course(dto) {
|
|
189
|
+
const out = [];
|
|
190
|
+
const push = (p) => p && out.push(p);
|
|
191
|
+
(dto.sessions ?? []).forEach((s, i) => {
|
|
192
|
+
const at = `$.sessions[${i}]`;
|
|
193
|
+
const startOk = TIME.test(s.start);
|
|
194
|
+
const endOk = TIME.test(s.end);
|
|
195
|
+
if (!startOk) out.push(`${at}.start: "${s.start}" is not a time. Write HH:MM, 24-hour.`);
|
|
196
|
+
if (!endOk) out.push(`${at}.end: "${s.end}" is not a time. Write HH:MM, 24-hour.`);
|
|
197
|
+
if (startOk && endOk && s.end <= s.start) out.push(`${at}: ends at ${s.end}, which is not after it starts (${s.start}).`);
|
|
198
|
+
for (const d of duplicates(s.days)) out.push(`${at}.days: "${d}" is listed more than once.`);
|
|
199
|
+
});
|
|
200
|
+
const assignments = dto.assignments ?? [];
|
|
201
|
+
for (const id of duplicates(assignments.map((a) => a.id))) out.push(`$.assignments: id "${id}" is used more than once.`);
|
|
202
|
+
assignments.forEach((a, i) => { if (a.due !== undefined) push(dayProblem(`$.assignments[${i}].due`, a.due)); });
|
|
203
|
+
const ids = new Set(assignments.map((a) => a.id));
|
|
204
|
+
const deadlines = dto.deadlines ?? [];
|
|
205
|
+
for (const id of duplicates(deadlines.map((d) => d.id))) out.push(`$.deadlines: id "${id}" is used more than once.`);
|
|
206
|
+
deadlines.forEach((d, i) => {
|
|
207
|
+
push(dayProblem(`$.deadlines[${i}].at`, d.at));
|
|
208
|
+
if (d.assignment !== undefined && !ids.has(d.assignment)) out.push(`$.deadlines[${i}].assignment: names "${d.assignment}", which is not an assignment in this course.`);
|
|
209
|
+
});
|
|
210
|
+
return out;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
piece(dto) {
|
|
214
|
+
const out = [];
|
|
215
|
+
const release = dto.release;
|
|
216
|
+
if (release?.date !== undefined) { const p = dayProblem("$.release.date", release.date); if (p) out.push(p); }
|
|
217
|
+
if (dto.status === "published" && release?.date === undefined) out.push("$.release.date: is missing. A published piece says the day it went out.");
|
|
218
|
+
if (dto.status === "idea" && release !== undefined) out.push("$.release: is present on an idea. An idea has not been released; set status to draft, published or retired first.");
|
|
219
|
+
return out;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
rule(dto) {
|
|
223
|
+
const out = [];
|
|
224
|
+
const push = (p) => p && out.push(p);
|
|
225
|
+
push(emptyProblem("$.statement", dto.statement, "A rule says something."));
|
|
226
|
+
const number = thresholdIn(dto.statement);
|
|
227
|
+
if (number !== null) out.push(`$.statement: sets a threshold, a count or a streak ("${number}"). A rule is never a number about a person's life; put any counting in the check, as a measurement.`);
|
|
228
|
+
push(dayProblem("$.since", dto.since));
|
|
229
|
+
push(emptyProblem("$.from.words", dto.from.words, "A rule carries the correction that made it, verbatim."));
|
|
230
|
+
push(emptyProblem("$.check", dto.check, "A rule is a test, and a test says how it could come back false."));
|
|
231
|
+
if (dto.status === "revised" && dto.revisedBy === undefined) out.push("$.revisedBy: is missing. A revised rule names the rule that replaced it.");
|
|
232
|
+
if (dto.status !== "revised" && dto.revisedBy !== undefined) out.push(`$.revisedBy: is present on a ${dto.status} rule. Only a revised rule names its replacement.`);
|
|
233
|
+
if (dto.revisedBy !== undefined && dto.revisedBy === dto.id) out.push("$.revisedBy: names this rule. A revision is a new rule.");
|
|
234
|
+
if (dto.status === "retired" && (dto.retiredBecause === undefined || dto.retiredBecause.trim() === "")) out.push("$.retiredBecause: is missing. A retired rule is kept with the reason it stopped fitting.");
|
|
235
|
+
if (dto.status !== "retired" && dto.retiredBecause !== undefined) out.push(`$.retiredBecause: is present on a ${dto.status} rule.`);
|
|
236
|
+
return out;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
hypothesis(dto) {
|
|
240
|
+
const out = [];
|
|
241
|
+
const push = (p) => p && out.push(p);
|
|
242
|
+
push(emptyProblem("$.claim", dto.claim, "A hypothesis is a claim stated so it could be wrong."));
|
|
243
|
+
push(emptyProblem("$.falsifier", dto.falsifier, "Say what would kill the claim, or say here that nothing yet can."));
|
|
244
|
+
push(dayProblem("$.raised.date", dto.raised.date));
|
|
245
|
+
if (dto.due !== undefined) push(dayProblem("$.due", dto.due));
|
|
246
|
+
if (dto.origin.kind === "reasoning" && dto.raised.by !== "agent") out.push("$.origin: is reasoning, but the owner raised this claim. Reasoning is an origin for an agent-raised claim only; the owner's claim has their words (quote) or a path.");
|
|
247
|
+
push(emptyProblem("$.origin.text", dto.origin.text, "An origin names the quote, the path, or the reasoning."));
|
|
248
|
+
(dto.pushback ?? []).forEach((p, i) => {
|
|
249
|
+
push(dayProblem(`$.pushback[${i}].date`, p.date));
|
|
250
|
+
push(emptyProblem(`$.pushback[${i}].words`, p.words, "Pushback is the owner's words, verbatim."));
|
|
251
|
+
});
|
|
252
|
+
(dto.evidence ?? []).forEach((e, i) => {
|
|
253
|
+
push(dayProblem(`$.evidence[${i}].date`, e.date));
|
|
254
|
+
push(emptyProblem(`$.evidence[${i}].source`, e.source, "Evidence names where it was found."));
|
|
255
|
+
});
|
|
256
|
+
if ((dto.status === "standing" || dto.status === "falsified") && !hasText(dto.outcome)) {
|
|
257
|
+
out.push(`$.outcome: is missing on a ${dto.status} claim. ${dto.status === "standing" ? "Standing means tested and survived" : "Falsified means tested and failed"}, so it says what the test found.`);
|
|
258
|
+
}
|
|
259
|
+
if (dto.status === "open" && hasText(dto.outcome)) out.push("$.outcome: is written on an open claim. An outcome is written only when the test resolves.");
|
|
260
|
+
if (dto.status === "refined" && dto.supersededBy === undefined) out.push("$.supersededBy: is missing on a refined claim. A refinement is a new entry, and this one names it.");
|
|
261
|
+
if (dto.supersededBy !== undefined && dto.supersededBy === dto.id) out.push("$.supersededBy: names this claim. A refinement is a new entry.");
|
|
262
|
+
if (dto.supersedes !== undefined && dto.supersedes === dto.id) out.push("$.supersedes: names this claim. A refinement is a new entry.");
|
|
263
|
+
return out;
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Read one entry of a person-layer kind. Returns { dto, problems }: problems is the shape parser's
|
|
268
|
+
// one message, or every cross-field problem; dto is null when the shape failed. `owner` is the
|
|
269
|
+
// record's holder id, which note and person-profile need.
|
|
270
|
+
export function readEntry(kind, raw, { owner } = {}) {
|
|
271
|
+
const { dto, problem } = parseShape(kind, raw);
|
|
272
|
+
if (problem) return { dto: null, problems: [problem] };
|
|
273
|
+
return { dto, problems: CHECKS[kind](dto, { owner }) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Whether a claim comes back on its own: only when `parked` was set false. A due date never does.
|
|
277
|
+
export const hypothesisSurfaces = (dto) => dto.parked === false;
|
|
278
|
+
|
|
279
|
+
// An entry file's contents as an object, or { error } when the file cannot be read as one, or null
|
|
280
|
+
// when the file is not an entry at all (markdown with no `kind:` in its frontmatter).
|
|
281
|
+
export function entryFromFile(name, text) {
|
|
282
|
+
if (/\.json$/i.test(name)) {
|
|
283
|
+
try { return { raw: JSON.parse(text) }; } catch (e) { return { error: `is not valid JSON (${e.message}).` }; }
|
|
284
|
+
}
|
|
285
|
+
if (/\.ya?ml$/i.test(name)) {
|
|
286
|
+
try { return { raw: parseYaml(text) }; } catch (e) { return { error: e.message }; }
|
|
287
|
+
}
|
|
288
|
+
if (/\.md$/i.test(name)) {
|
|
289
|
+
const lines = text.split("\n");
|
|
290
|
+
if (lines[0] !== "---") return null;
|
|
291
|
+
const end = lines.indexOf("---", 1);
|
|
292
|
+
if (end === -1) return null;
|
|
293
|
+
let front;
|
|
294
|
+
try { front = parseYaml(lines.slice(1, end).join("\n")); } catch (e) { return /^kind:/m.test(lines.slice(1, end).join("\n")) ? { error: e.message } : null; }
|
|
295
|
+
if (!isObject(front) || front.kind === undefined) return null;
|
|
296
|
+
const raw = { ...front };
|
|
297
|
+
if (raw.kind === "note" && absent(raw.words)) raw.words = lines.slice(end + 1).join("\n").trim();
|
|
298
|
+
return { raw };
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|