xdrs-core 0.7.1 → 0.9.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/.xdrs/_core/adrs/index.md +9 -1
- package/.xdrs/_core/adrs/principles/001-xdr-standards.md +51 -9
- package/.xdrs/_core/adrs/principles/003-skill-standards.md +12 -4
- package/.xdrs/_core/adrs/principles/004-article-standards.md +12 -8
- package/.xdrs/_core/adrs/principles/005-semantic-versioning-for-xdr-packages.md +5 -4
- package/.xdrs/_core/adrs/principles/006-research-standards.md +127 -0
- package/.xdrs/_core/adrs/principles/articles/001-xdrs-overview.md +94 -66
- package/.xdrs/_core/adrs/principles/researches/001-research-and-decision-lifecycle.md +69 -0
- package/.xdrs/_core/adrs/principles/skills/001-lint/SKILL.md +3 -3
- package/.xdrs/_core/adrs/principles/skills/002-write-xdr/SKILL.md +38 -10
- package/.xdrs/_core/adrs/principles/skills/003-write-skill/SKILL.md +10 -4
- package/.xdrs/_core/adrs/principles/skills/004-write-article/SKILL.md +16 -11
- package/.xdrs/_core/adrs/principles/skills/005-write-research/SKILL.md +256 -0
- package/AGENTS.md +0 -2
- package/README.md +26 -12
- package/lib/lint.js +303 -2
- package/package.json +4 -5
- package/AGENTS.local.md +0 -3
package/README.md
CHANGED
|
@@ -12,11 +12,14 @@ This project defines a standard for organizing XDRs that satisfies the following
|
|
|
12
12
|
|
|
13
13
|
## XDR elements
|
|
14
14
|
|
|
15
|
-
Every XDR package contains
|
|
15
|
+
Every XDR package contains four types of documents:
|
|
16
16
|
|
|
17
|
-
- **Decision Records (XDRs)** — Architectural (ADR), Business (BDR), or Engineering (EDR) records that capture a single decision, its rationale, and the rules that follow from it. They are the source of truth.
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
17
|
+
- **Decision Records (XDRs)** — Architectural (ADR), Business (BDR), or Engineering (EDR) records that capture a single decision, its rationale, and the rules that follow from it. They are the source of truth. An XDR may optionally start with a `Metadata` section for short applicability and validity markers.
|
|
18
|
+
- **Research** — Exploratory documents that capture the problem being investigated, constraints or requirements, findings, and option tradeoffs that back a decision during its lifecycle. One research document may inform multiple downstream decisions, but it is not a replacement for the Decision Record.
|
|
19
|
+
- **Skills** — Step-by-step procedural guides that can be followed by humans, AI agents, or both. Skills are task-based artifacts with a concrete outcome and should include enough detail to verify the task was completed correctly. A skill may start as a fully manual procedure and evolve toward partial or full AI automation over time. Co-located with the XDRs they implement.
|
|
20
|
+
- **Articles** — Synthetic explanatory texts that combine information from multiple XDRs, Research documents, and Skills around a specific topic or audience. They never replace XDRs as source of truth.
|
|
21
|
+
|
|
22
|
+
For simple indications, prefer plain Markdown, tables, or ASCII art. Use local images and other supporting files only when they are materially necessary, and keep them in a sibling `assets/` folder next to the document file.
|
|
20
23
|
|
|
21
24
|
## Getting started
|
|
22
25
|
|
|
@@ -51,8 +54,11 @@ The `lint` command reads `./.xdrs/**` from the given workspace path and checks c
|
|
|
51
54
|
- XDR numbering uniqueness per `scope/type`
|
|
52
55
|
- skill numbering uniqueness per `scope/type/subject/skills`
|
|
53
56
|
- article numbering uniqueness per `scope/type/subject/articles`
|
|
57
|
+
- research numbering uniqueness per `scope/type/subject/researches`
|
|
54
58
|
- canonical index presence and link consistency
|
|
55
59
|
- root index coverage for all discovered canonical indexes
|
|
60
|
+
- XDR metadata section placement and `Applied to` / `Validity` field format
|
|
61
|
+
- local image and `assets/` links resolving inside the sibling `assets/` folder for each document
|
|
56
62
|
|
|
57
63
|
Examples:
|
|
58
64
|
|
|
@@ -106,11 +112,17 @@ This is especially important for BDRs: because business rules govern decisions t
|
|
|
106
112
|
index.md # canonical index for this scope+type
|
|
107
113
|
[subject]/
|
|
108
114
|
[number]-[short-title].md # individual decision record
|
|
115
|
+
assets/ # optional local resources for subject-level XDR files
|
|
116
|
+
researches/ # optional decision-backing research documents
|
|
117
|
+
[number]-[short-title].md
|
|
118
|
+
assets/
|
|
109
119
|
skills/ # optional skill packages for humans and AI agents
|
|
110
120
|
[number]-[skill-name]/
|
|
111
121
|
SKILL.md
|
|
112
|
-
|
|
122
|
+
assets/
|
|
123
|
+
articles/ # optional synthetic views over XDRs, Research, and Skills
|
|
113
124
|
[number]-[short-title].md
|
|
125
|
+
assets/
|
|
114
126
|
```
|
|
115
127
|
|
|
116
128
|
Document types:
|
|
@@ -118,31 +130,32 @@ Document types:
|
|
|
118
130
|
- **ADR** - Architectural Decision Record: architectural and technical decisions
|
|
119
131
|
- **BDR** - Business Decision Record: business process and strategy decisions
|
|
120
132
|
- **EDR** - Engineering Decision Record: engineering workflow and tooling decisions
|
|
121
|
-
- **
|
|
122
|
-
- **
|
|
133
|
+
- **Research** - Exploratory support material used while evaluating or updating a decision. Research captures constraints, findings, options, and proposal tradeoffs, but it is not the source of truth.
|
|
134
|
+
- **Skills** - Step-by-step procedural guides that can be followed by humans, AI agents, or both. Must comply with Decision Records, but add the execution detail they lack. Skills are not mandatory by themselves unless referenced by an XDR or another policy artifact. A skill may start as a fully manual human procedure and evolve incrementally toward partial or full AI automation without being restructured. Co-located with the XDRs they implement inside `skills/` sub-directories.
|
|
135
|
+
- **Articles** - Synthetic views that explain concepts or combine information from multiple Decision Records, Research documents, and Skills into a coherent text for a specific topic or audience. Articles are not the source of truth; Decision Records take precedence when there is a conflict. Useful as navigational indexes that link related artifacts around a specific aspect.
|
|
123
136
|
|
|
124
137
|
See [.xdrs/index.md](.xdrs/index.md) for the full list of active decision records.
|
|
125
138
|
|
|
126
139
|
For a deeper overview of XDRs — objective, structure, guidelines, extension, and usage — see the [XDRs Overview article](.xdrs/_core/adrs/principles/articles/001-xdrs-overview.md).
|
|
127
|
-
For packaging guidance on publishing your own reusable scope with DRs, skills, and articles, see the [Create your own xdrs-core extension package article](docs/create-your-own-xdrs-extension-package.md), then compare [examples/basic-usage](examples/basic-usage) and [examples/mydevkit](examples/mydevkit).
|
|
140
|
+
For packaging guidance on publishing your own reusable scope with DRs, Research documents, skills, and articles, see the [Create your own xdrs-core extension package article](docs/create-your-own-xdrs-extension-package.md), then compare [examples/basic-usage](examples/basic-usage) and [examples/mydevkit](examples/mydevkit).
|
|
128
141
|
|
|
129
142
|
## Flow: Decision -> Distribution -> Usage
|
|
130
143
|
|
|
131
|
-
XDRs and skills follow a three-stage lifecycle that keeps decision-making decentralized while allowing controlled adoption across projects.
|
|
144
|
+
XDRs, Research documents, and skills follow a three-stage lifecycle that keeps decision-making decentralized while allowing controlled adoption across projects.
|
|
132
145
|
|
|
133
146
|
### Decision
|
|
134
147
|
|
|
135
|
-
Each scope manages its own set of
|
|
148
|
+
Each scope manages its own set of XDR artifacts independently. Scope owners discuss and evolve decisions through whatever process fits their team, such as RFCs, pull requests, or architecture review boards. Research documents, XDRs, and skills are authored, reviewed, and merged within the scope's folder in the repository.
|
|
136
149
|
|
|
137
150
|
### Distribution
|
|
138
151
|
|
|
139
152
|
Once a set of decisions is ready to share, scope owners pack the relevant `.xdrs/[scope]/` folder into a versioned npm package using a tool such as [filedist](https://github.com/flaviostutz/filedist) and publish it to an npm registry, either public or a company-internal one. Versioning gives consumers explicit control over which revision of a scope's decisions they adopt, avoiding situations where a single breaking policy change is forced on all consumers at once.
|
|
140
153
|
|
|
141
|
-
The same applies to skills: because they live alongside XDRs inside the scope folder, they are included in the same package and published together.
|
|
154
|
+
The same applies to Research documents, skills, articles, and any sibling `assets/` folders: because they live alongside XDRs inside the scope folder, they are included in the same package and published together.
|
|
142
155
|
|
|
143
156
|
### Usage
|
|
144
157
|
|
|
145
|
-
A project that wants to follow a scope's decisions adds the corresponding npm package as a regular dependency. Using a tool such as [filedist](https://github.com/flaviostutz/filedist), the package contents are unpacked into the project's `.xdrs/` folder at install or update time. Updating the dependency version pulls in the latest XDRs and skills for that scope, keeping the project aligned with the scope owners' current decisions.
|
|
158
|
+
A project that wants to follow a scope's decisions adds the corresponding npm package as a regular dependency. Using a tool such as [filedist](https://github.com/flaviostutz/filedist), the package contents are unpacked into the project's `.xdrs/` folder at install or update time. Updating the dependency version pulls in the latest XDRs, Research documents, and skills for that scope, keeping the project aligned with the scope owners' current decisions.
|
|
146
159
|
|
|
147
160
|
Multiple scope packages can be combined in the same workspace by listing them as separate dependencies. Scope precedence (defined in `.xdrs/index.md`) determines which decisions take effect when scopes overlap.
|
|
148
161
|
|
|
@@ -152,6 +165,7 @@ Multiple scope packages can be combined in the same workspace by listing them as
|
|
|
152
165
|
adrs/ bdrs/ edrs/ adrs/ bdrs/ edrs/
|
|
153
166
|
[subject]/ [subject]/
|
|
154
167
|
*.md *.md
|
|
168
|
+
researches/ researches/
|
|
155
169
|
skills/ skills/
|
|
156
170
|
articles/ articles/
|
|
157
171
|
```
|
package/lib/lint.js
CHANGED
|
@@ -21,6 +21,9 @@ const RESERVED_SCOPES = new Set(['_core', '_local']);
|
|
|
21
21
|
const NUMBERED_FILE_RE = /^(\d{3,})-([a-z0-9-]+)\.md$/;
|
|
22
22
|
const NUMBERED_DIR_RE = /^(\d{3,})-([a-z0-9-]+)$/;
|
|
23
23
|
const REQUIRED_ROOT_INDEX_TEXT = 'XDRs in scopes listed last override the ones listed first';
|
|
24
|
+
const SUBJECT_ARTIFACT_DIRS = new Set(['skills', 'articles', 'researches']);
|
|
25
|
+
const RESOURCE_DIR_NAME = 'assets';
|
|
26
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp']);
|
|
24
27
|
|
|
25
28
|
function runLintCli(args) {
|
|
26
29
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -190,6 +193,9 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
|
|
|
190
193
|
const entryPath = path.join(subjectPath, entry.name);
|
|
191
194
|
|
|
192
195
|
if (entry.isDirectory()) {
|
|
196
|
+
if (entry.name === RESOURCE_DIR_NAME) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
193
199
|
if (entry.name === 'skills') {
|
|
194
200
|
artifacts.push(...lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
|
|
195
201
|
continue;
|
|
@@ -198,6 +204,10 @@ function lintSubjectDirectory(xdrsRoot, scopeName, typeName, subjectName, xdrNum
|
|
|
198
204
|
artifacts.push(...lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
|
|
199
205
|
continue;
|
|
200
206
|
}
|
|
207
|
+
if (entry.name === 'researches') {
|
|
208
|
+
artifacts.push(...lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, entryPath, errors));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
201
211
|
|
|
202
212
|
errors.push(`Unexpected directory under ${scopeName}/${typeName}/${subjectName}: ${toDisplayPath(entryPath)}`);
|
|
203
213
|
continue;
|
|
@@ -240,6 +250,120 @@ function lintXdrFile(xdrsRoot, scopeName, typeName, filePath, xdrNumbers, errors
|
|
|
240
250
|
if (!firstLine.startsWith(expectedHeader)) {
|
|
241
251
|
errors.push(`XDR title must start with "${expectedHeader}": ${toDisplayPath(filePath)}`);
|
|
242
252
|
}
|
|
253
|
+
|
|
254
|
+
lintXdrMetadata(content, filePath, errors);
|
|
255
|
+
lintDocumentResourceLinks(filePath, errors);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function lintXdrMetadata(content, filePath, errors) {
|
|
259
|
+
const lines = content.split(/\r?\n/);
|
|
260
|
+
const ignoredLines = findIgnoredMarkdownLines(lines);
|
|
261
|
+
const metadataLine = findHeadingLine(lines, ignoredLines, '## Metadata');
|
|
262
|
+
const contextLine = findHeadingLine(lines, ignoredLines, '## Context and Problem Statement');
|
|
263
|
+
const headingLines = findHeadingLines(lines, ignoredLines);
|
|
264
|
+
|
|
265
|
+
if (metadataLine !== -1) {
|
|
266
|
+
const nextHeadingLine = headingLines.find((lineIndex) => lineIndex > metadataLine);
|
|
267
|
+
if (nextHeadingLine !== contextLine) {
|
|
268
|
+
errors.push(`XDR metadata section must appear immediately before Context and Problem Statement: ${toDisplayPath(filePath)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (metadataLine !== -1 && contextLine !== -1 && metadataLine > contextLine) {
|
|
273
|
+
errors.push(`XDR metadata section must appear before Context and Problem Statement: ${toDisplayPath(filePath)}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const metadataRange = metadataLine === -1
|
|
277
|
+
? null
|
|
278
|
+
: {
|
|
279
|
+
start: metadataLine + 1,
|
|
280
|
+
end: (headingLines.find((lineIndex) => lineIndex > metadataLine) ?? lines.length) - 1
|
|
281
|
+
};
|
|
282
|
+
const appliedLineNumbers = findFieldLines(lines, ignoredLines, 'Applied to:');
|
|
283
|
+
const validityLineNumbers = findFieldLines(lines, ignoredLines, 'Validity:');
|
|
284
|
+
|
|
285
|
+
if (!metadataRange && (appliedLineNumbers.length > 0 || validityLineNumbers.length > 0)) {
|
|
286
|
+
errors.push(`XDR metadata fields require a ## Metadata section immediately before Context and Problem Statement: ${toDisplayPath(filePath)}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!metadataRange) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const metadataApplied = appliedLineNumbers.filter((lineIndex) => isLineInsideRange(lineIndex, metadataRange));
|
|
295
|
+
const metadataValidity = validityLineNumbers.filter((lineIndex) => isLineInsideRange(lineIndex, metadataRange));
|
|
296
|
+
|
|
297
|
+
if (metadataApplied.length === 0 && metadataValidity.length === 0) {
|
|
298
|
+
errors.push(`XDR metadata section must be omitted when no metadata fields are defined: ${toDisplayPath(filePath)}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (metadataApplied.length > 1) {
|
|
302
|
+
errors.push(`XDR metadata must not repeat Applied to: ${toDisplayPath(filePath)}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (metadataValidity.length > 1) {
|
|
306
|
+
errors.push(`XDR metadata must not repeat Validity: ${toDisplayPath(filePath)}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const lineIndex of [...appliedLineNumbers, ...validityLineNumbers]) {
|
|
310
|
+
if (!isLineInsideRange(lineIndex, metadataRange)) {
|
|
311
|
+
errors.push(`XDR metadata fields must be declared inside ## Metadata: ${toDisplayPath(filePath)}`);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (let lineIndex = metadataRange.start; lineIndex <= metadataRange.end; lineIndex += 1) {
|
|
317
|
+
if (ignoredLines[lineIndex]) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const trimmed = lines[lineIndex].trim();
|
|
321
|
+
if (trimmed === '') {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!trimmed.startsWith('Applied to:') && !trimmed.startsWith('Validity:')) {
|
|
325
|
+
errors.push(`XDR metadata section only supports Applied to: and Validity: fields: ${toDisplayPath(filePath)}`);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (metadataApplied.length === 1) {
|
|
331
|
+
const value = lines[metadataApplied[0]].slice('Applied to:'.length).trim();
|
|
332
|
+
if (value === '') {
|
|
333
|
+
errors.push(`XDR Applied to: must not be empty: ${toDisplayPath(filePath)}`);
|
|
334
|
+
} else if (countWords(value) >= 40) {
|
|
335
|
+
errors.push(`XDR Applied to: must be under 40 words: ${toDisplayPath(filePath)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (metadataValidity.length === 1) {
|
|
340
|
+
lintValidityField(lines[metadataValidity[0]], filePath, errors);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function lintValidityField(line, filePath, errors) {
|
|
345
|
+
const value = line.slice('Validity:'.length).trim().replace(/\.$/, '');
|
|
346
|
+
|
|
347
|
+
if (value === 'Draft' || value === 'Retired') {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const validityMatch = value.match(/^(?:from\s+(\d{4}-\d{2}-\d{2}))?(?:\s+until\s+(\d{4}-\d{2}-\d{2}))?$/);
|
|
352
|
+
if (!validityMatch || (!validityMatch[1] && !validityMatch[2])) {
|
|
353
|
+
errors.push(`XDR Validity: must be Draft, Retired, from YYYY-MM-DD, until YYYY-MM-DD, or from YYYY-MM-DD until YYYY-MM-DD: ${toDisplayPath(filePath)}`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const fromDate = validityMatch[1];
|
|
358
|
+
const untilDate = validityMatch[2];
|
|
359
|
+
if ((fromDate && !isIsoDate(fromDate)) || (untilDate && !isIsoDate(untilDate))) {
|
|
360
|
+
errors.push(`XDR Validity: must use ISO dates in YYYY-MM-DD format: ${toDisplayPath(filePath)}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (fromDate && untilDate && fromDate > untilDate) {
|
|
365
|
+
errors.push(`XDR Validity: from date must be on or before until date: ${toDisplayPath(filePath)}`);
|
|
366
|
+
}
|
|
243
367
|
}
|
|
244
368
|
|
|
245
369
|
function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsPath, errors) {
|
|
@@ -287,6 +411,8 @@ function lintSkillsDirectory(xdrsRoot, scopeName, typeName, subjectName, skillsP
|
|
|
287
411
|
} else if (frontmatterName !== entry.name) {
|
|
288
412
|
errors.push(`Skill frontmatter name must match package directory "${entry.name}": ${toDisplayPath(skillFilePath)}`);
|
|
289
413
|
}
|
|
414
|
+
|
|
415
|
+
lintDocumentResourceLinks(skillFilePath, errors);
|
|
290
416
|
}
|
|
291
417
|
|
|
292
418
|
return artifacts;
|
|
@@ -299,6 +425,10 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
|
|
|
299
425
|
|
|
300
426
|
for (const entry of entries) {
|
|
301
427
|
const entryPath = path.join(articlesPath, entry.name);
|
|
428
|
+
if (entry.isDirectory() && entry.name === RESOURCE_DIR_NAME) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
302
432
|
if (!entry.isFile()) {
|
|
303
433
|
errors.push(`Unexpected directory in articles folder: ${toDisplayPath(entryPath)}`);
|
|
304
434
|
continue;
|
|
@@ -330,6 +460,57 @@ function lintArticlesDirectory(xdrsRoot, scopeName, typeName, subjectName, artic
|
|
|
330
460
|
if (!firstLine.startsWith(expectedHeader)) {
|
|
331
461
|
errors.push(`Article title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
|
|
332
462
|
}
|
|
463
|
+
|
|
464
|
+
lintDocumentResourceLinks(entryPath, errors);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return artifacts;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function lintResearchDirectory(xdrsRoot, scopeName, typeName, subjectName, researchPath, errors) {
|
|
471
|
+
const artifacts = [];
|
|
472
|
+
const researchNumbers = new Map();
|
|
473
|
+
const entries = safeReadDir(researchPath, errors, `read research directory ${scopeName}/${typeName}/${subjectName}/researches`);
|
|
474
|
+
|
|
475
|
+
for (const entry of entries) {
|
|
476
|
+
const entryPath = path.join(researchPath, entry.name);
|
|
477
|
+
if (entry.isDirectory() && entry.name === RESOURCE_DIR_NAME) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!entry.isFile()) {
|
|
482
|
+
errors.push(`Unexpected directory in researches folder: ${toDisplayPath(entryPath)}`);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const match = entry.name.match(NUMBERED_FILE_RE);
|
|
487
|
+
if (!match) {
|
|
488
|
+
errors.push(`Invalid research file name: ${toDisplayPath(entryPath)}`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
artifacts.push(entryPath);
|
|
493
|
+
|
|
494
|
+
const number = match[1];
|
|
495
|
+
const previous = researchNumbers.get(number);
|
|
496
|
+
if (previous) {
|
|
497
|
+
errors.push(`Duplicate research number ${number} in ${scopeName}/${typeName}/${subjectName}/researches: ${toDisplayPath(previous)} and ${toDisplayPath(entryPath)}`);
|
|
498
|
+
} else {
|
|
499
|
+
researchNumbers.set(number, entryPath);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (entry.name !== entry.name.toLowerCase()) {
|
|
503
|
+
errors.push(`Research file name must be lowercase: ${toDisplayPath(entryPath)}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const content = fs.readFileSync(entryPath, 'utf8');
|
|
507
|
+
const expectedHeader = `# ${scopeName}-research-${number}:`;
|
|
508
|
+
const firstLine = firstNonEmptyLine(content);
|
|
509
|
+
if (!firstLine.startsWith(expectedHeader)) {
|
|
510
|
+
errors.push(`Research title must start with "${expectedHeader}": ${toDisplayPath(entryPath)}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
lintDocumentResourceLinks(entryPath, errors);
|
|
333
514
|
}
|
|
334
515
|
|
|
335
516
|
return artifacts;
|
|
@@ -356,15 +537,43 @@ function lintTypeIndex(indexPath, xdrsRoot, artifacts, errors) {
|
|
|
356
537
|
}
|
|
357
538
|
}
|
|
358
539
|
|
|
540
|
+
function lintDocumentResourceLinks(documentPath, errors) {
|
|
541
|
+
const content = fs.readFileSync(documentPath, 'utf8');
|
|
542
|
+
const documentDir = path.dirname(documentPath);
|
|
543
|
+
const resourceDir = path.join(documentDir, RESOURCE_DIR_NAME);
|
|
544
|
+
|
|
545
|
+
for (const link of parseLocalLinkTargets(content, documentDir)) {
|
|
546
|
+
if (!shouldValidateResourceLink(link.rawTarget)) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!fs.existsSync(link.resolvedPath)) {
|
|
551
|
+
errors.push(`Broken asset link in ${toDisplayPath(documentPath)}: ${link.rawTarget}`);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!isPathInside(resourceDir, link.resolvedPath)) {
|
|
556
|
+
errors.push(`Asset links in ${toDisplayPath(documentPath)} must point to ${toDisplayPath(resourceDir)}: ${link.rawTarget}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
359
561
|
function parseLocalLinks(markdown, baseDir) {
|
|
562
|
+
return parseLocalLinkTargets(markdown, baseDir).map((link) => link.resolvedPath);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function parseLocalLinkTargets(markdown, baseDir) {
|
|
360
566
|
const links = [];
|
|
361
|
-
const linkRe =
|
|
567
|
+
const linkRe = /!?\[[^\]]+\]\(([^)]+)\)/g;
|
|
362
568
|
let match = linkRe.exec(markdown);
|
|
363
569
|
while (match) {
|
|
364
570
|
const rawTarget = match[1].trim();
|
|
365
571
|
if (isLocalLink(rawTarget)) {
|
|
366
572
|
const targetWithoutAnchor = rawTarget.split('#')[0];
|
|
367
|
-
links.push(
|
|
573
|
+
links.push({
|
|
574
|
+
rawTarget,
|
|
575
|
+
resolvedPath: path.resolve(baseDir, targetWithoutAnchor)
|
|
576
|
+
});
|
|
368
577
|
}
|
|
369
578
|
match = linkRe.exec(markdown);
|
|
370
579
|
}
|
|
@@ -384,6 +593,17 @@ function isCanonicalTypeIndex(filePath, xdrsRoot) {
|
|
|
384
593
|
return relative.length === 3 && TYPE_NAMES.has(relative[1]) && relative[2] === 'index.md';
|
|
385
594
|
}
|
|
386
595
|
|
|
596
|
+
function shouldValidateResourceLink(rawTarget) {
|
|
597
|
+
const targetWithoutAnchor = rawTarget.split('#')[0];
|
|
598
|
+
const normalizedTarget = targetWithoutAnchor.replace(/\\/g, '/');
|
|
599
|
+
const extension = path.extname(targetWithoutAnchor).toLowerCase();
|
|
600
|
+
|
|
601
|
+
return normalizedTarget === RESOURCE_DIR_NAME
|
|
602
|
+
|| normalizedTarget.startsWith(`${RESOURCE_DIR_NAME}/`)
|
|
603
|
+
|| normalizedTarget.includes(`/${RESOURCE_DIR_NAME}/`)
|
|
604
|
+
|| IMAGE_EXTENSIONS.has(extension);
|
|
605
|
+
}
|
|
606
|
+
|
|
387
607
|
function extractFrontmatterName(content) {
|
|
388
608
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
389
609
|
if (!frontmatterMatch) {
|
|
@@ -394,6 +614,82 @@ function extractFrontmatterName(content) {
|
|
|
394
614
|
return nameMatch ? nameMatch[1].trim() : null;
|
|
395
615
|
}
|
|
396
616
|
|
|
617
|
+
function findIgnoredMarkdownLines(lines) {
|
|
618
|
+
const ignored = [];
|
|
619
|
+
let inCodeFence = false;
|
|
620
|
+
|
|
621
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
622
|
+
const trimmed = lines[index].trim();
|
|
623
|
+
if (/^```/.test(trimmed)) {
|
|
624
|
+
ignored[index] = true;
|
|
625
|
+
inCodeFence = !inCodeFence;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
ignored[index] = inCodeFence;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return ignored;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function findHeadingLine(lines, ignoredLines, headingText) {
|
|
636
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
637
|
+
if (ignoredLines[index]) {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (lines[index].trim() === headingText) {
|
|
641
|
+
return index;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return -1;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function findHeadingLines(lines, ignoredLines) {
|
|
648
|
+
const result = [];
|
|
649
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
650
|
+
if (ignoredLines[index]) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (/^##\s+/.test(lines[index].trim())) {
|
|
654
|
+
result.push(index);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function findFieldLines(lines, ignoredLines, prefix) {
|
|
661
|
+
const result = [];
|
|
662
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
663
|
+
if (ignoredLines[index]) {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (lines[index].trim().startsWith(prefix)) {
|
|
667
|
+
result.push(index);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return result;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function isLineInsideRange(lineIndex, range) {
|
|
674
|
+
return lineIndex >= range.start && lineIndex <= range.end;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function countWords(value) {
|
|
678
|
+
return value.split(/\s+/).filter(Boolean).length;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isIsoDate(value) {
|
|
682
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
687
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
688
|
+
return date.getUTCFullYear() === year
|
|
689
|
+
&& date.getUTCMonth() === month - 1
|
|
690
|
+
&& date.getUTCDate() === day;
|
|
691
|
+
}
|
|
692
|
+
|
|
397
693
|
function firstNonEmptyLine(content) {
|
|
398
694
|
const lines = content.split(/\r?\n/);
|
|
399
695
|
for (const line of lines) {
|
|
@@ -452,6 +748,11 @@ function normalizePath(filePath) {
|
|
|
452
748
|
return path.normalize(filePath);
|
|
453
749
|
}
|
|
454
750
|
|
|
751
|
+
function isPathInside(parentPath, childPath) {
|
|
752
|
+
const relative = path.relative(parentPath, childPath);
|
|
753
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
754
|
+
}
|
|
755
|
+
|
|
455
756
|
module.exports = {
|
|
456
757
|
runLintCli,
|
|
457
758
|
lintWorkspace
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xdrs-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A standard way to organize Decision Records (XDRs) across scopes, subjects, and teams so that AI agents can reliably query and follow them.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
],
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"files": [
|
|
17
|
-
".xdrs/**",
|
|
17
|
+
".xdrs/_core/**",
|
|
18
|
+
".xdrs/index.md",
|
|
18
19
|
"package.json",
|
|
19
20
|
"AGENTS.md",
|
|
20
|
-
"AGENTS.local.md",
|
|
21
21
|
"bin/filedist.js",
|
|
22
22
|
"lib/**/*.js"
|
|
23
23
|
],
|
|
@@ -47,8 +47,7 @@
|
|
|
47
47
|
{
|
|
48
48
|
"selector": {
|
|
49
49
|
"files": [
|
|
50
|
-
".xdrs/index.md"
|
|
51
|
-
"AGENTS.local.md"
|
|
50
|
+
".xdrs/index.md"
|
|
52
51
|
]
|
|
53
52
|
},
|
|
54
53
|
"output": {
|
package/AGENTS.local.md
DELETED