@descent-vtt/spec-brief 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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +269 -0
- package/bin/spec-brief.js +19 -0
- package/dist/apply.d.ts +26 -0
- package/dist/apply.js +71 -0
- package/dist/apply.js.map +1 -0
- package/dist/archive.d.ts +81 -0
- package/dist/archive.js +333 -0
- package/dist/archive.js.map +1 -0
- package/dist/brief.d.ts +60 -0
- package/dist/brief.js +152 -0
- package/dist/brief.js.map +1 -0
- package/dist/cli.d.ts +35 -0
- package/dist/cli.js +411 -0
- package/dist/cli.js.map +1 -0
- package/dist/collisions.d.ts +50 -0
- package/dist/collisions.js +127 -0
- package/dist/collisions.js.map +1 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +353 -0
- package/dist/config.js.map +1 -0
- package/dist/corpus.d.ts +41 -0
- package/dist/corpus.js +154 -0
- package/dist/corpus.js.map +1 -0
- package/dist/engine.d.ts +121 -0
- package/dist/engine.js +276 -0
- package/dist/engine.js.map +1 -0
- package/dist/frontmatter.d.ts +68 -0
- package/dist/frontmatter.js +311 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/fs.d.ts +59 -0
- package/dist/fs.js +189 -0
- package/dist/fs.js.map +1 -0
- package/dist/git.d.ts +59 -0
- package/dist/git.js +131 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +79 -0
- package/dist/glob.js +465 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +11 -0
- package/dist/integrity.js +20 -0
- package/dist/integrity.js.map +1 -0
- package/dist/links.d.ts +38 -0
- package/dist/links.js +142 -0
- package/dist/links.js.map +1 -0
- package/dist/lint.d.ts +38 -0
- package/dist/lint.js +90 -0
- package/dist/lint.js.map +1 -0
- package/dist/markdown.d.ts +65 -0
- package/dist/markdown.js +274 -0
- package/dist/markdown.js.map +1 -0
- package/dist/plugins.d.ts +16 -0
- package/dist/plugins.js +77 -0
- package/dist/plugins.js.map +1 -0
- package/dist/report.d.ts +38 -0
- package/dist/report.js +244 -0
- package/dist/report.js.map +1 -0
- package/dist/rules.d.ts +58 -0
- package/dist/rules.js +448 -0
- package/dist/rules.js.map +1 -0
- package/dist/scaffold.d.ts +25 -0
- package/dist/scaffold.js +81 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/schema.d.ts +47 -0
- package/dist/schema.js +195 -0
- package/dist/schema.js.map +1 -0
- package/dist/text.d.ts +40 -0
- package/dist/text.js +95 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +76 -0
- package/schema.json +321 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The collision matrix: which briefs scheduled to run side by side declare
|
|
3
|
+
* scopes that can name the same file.
|
|
4
|
+
*
|
|
5
|
+
* Scopes are compared as globs, not as strings. `src/auth/**` and
|
|
6
|
+
* `src/**\/session.ts` share no prefix a string comparison would see and both
|
|
7
|
+
* cover `src/auth/session.ts`; the intersection finds that, and names the file.
|
|
8
|
+
* A brief that declares no scope cannot be proven apart from anything, so it
|
|
9
|
+
* is reported as unscoped rather than silently counted as safe.
|
|
10
|
+
*/
|
|
11
|
+
import { lineOfField } from './brief.js';
|
|
12
|
+
import { globBase, intersectGlobs, parseGlob } from './glob.js';
|
|
13
|
+
import { severityOf } from './lint.js';
|
|
14
|
+
import { COLLISION_RULES } from './rules.js';
|
|
15
|
+
function scopes(brief, isFile) {
|
|
16
|
+
return brief.affectedFiles.flatMap((pattern) => {
|
|
17
|
+
const parsed = parseGlob(pattern, { isFile });
|
|
18
|
+
return parsed.ok ? [{ pattern, glob: parsed.glob }] : [];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function matrix(wave, briefs, isFile) {
|
|
22
|
+
const collisions = [];
|
|
23
|
+
const shared = [];
|
|
24
|
+
const scoped = briefs.map((brief) => ({ brief, scopes: scopes(brief, isFile) }));
|
|
25
|
+
for (let i = 0; i < scoped.length; i += 1) {
|
|
26
|
+
for (let j = i + 1; j < scoped.length; j += 1) {
|
|
27
|
+
const left = scoped[i];
|
|
28
|
+
const right = scoped[j];
|
|
29
|
+
const found = [];
|
|
30
|
+
for (const x of left.scopes) {
|
|
31
|
+
for (const y of right.scopes) {
|
|
32
|
+
const witness = intersectGlobs(x.glob, y.glob);
|
|
33
|
+
if (witness !== null)
|
|
34
|
+
found.push({ a: left.brief, b: right.brief, patterns: [x.pattern, y.pattern], witness });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
collisions.push(...found);
|
|
38
|
+
if (found.length > 0)
|
|
39
|
+
continue;
|
|
40
|
+
const leftDirs = new Set(left.scopes.map((s) => globBase(s.glob)).filter((d) => d !== ''));
|
|
41
|
+
const common = [...new Set(right.scopes.map((s) => globBase(s.glob)))].filter((d) => leftDirs.has(d)).sort();
|
|
42
|
+
for (const directory of common)
|
|
43
|
+
shared.push({ a: left.brief, b: right.brief, directory });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const unscoped = briefs.length > 1 ? scoped.filter((s) => s.scopes.length === 0).map((s) => s.brief) : [];
|
|
47
|
+
return { wave, briefs, collisions, shared, unscoped };
|
|
48
|
+
}
|
|
49
|
+
export function collisions(corpus, options = {}) {
|
|
50
|
+
const files = options.repoFiles === undefined || options.repoFiles === null ? null : new Set(options.repoFiles);
|
|
51
|
+
const isFile = files === null ? undefined : (path) => files.has(path);
|
|
52
|
+
if (options.all === true)
|
|
53
|
+
return { waves: [matrix(null, corpus.live, isFile)], unscheduled: [] };
|
|
54
|
+
const byWave = new Map();
|
|
55
|
+
const unscheduled = [];
|
|
56
|
+
for (const brief of corpus.live) {
|
|
57
|
+
if (brief.wave === null)
|
|
58
|
+
unscheduled.push(brief);
|
|
59
|
+
else
|
|
60
|
+
byWave.set(brief.wave, [...(byWave.get(brief.wave) ?? []), brief]);
|
|
61
|
+
}
|
|
62
|
+
const waves = [...byWave.keys()].sort((a, b) => a - b).map((wave) => matrix(wave, byWave.get(wave), isFile));
|
|
63
|
+
return { waves, unscheduled };
|
|
64
|
+
}
|
|
65
|
+
function label(brief) {
|
|
66
|
+
return brief.id ?? brief.name;
|
|
67
|
+
}
|
|
68
|
+
function where(wave) {
|
|
69
|
+
return wave === null ? 'among the live briefs' : `in wave ${wave}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The report as findings. One collision is one finding, placed on the later
|
|
73
|
+
* brief of the pair, which is usually the one still being written.
|
|
74
|
+
*/
|
|
75
|
+
export function collisionFindings(corpus, report) {
|
|
76
|
+
const severity = (id) => {
|
|
77
|
+
const rule = COLLISION_RULES.find((r) => r.id === id);
|
|
78
|
+
const setting = severityOf(corpus, id, rule.severity);
|
|
79
|
+
return setting === 'off' ? null : setting;
|
|
80
|
+
};
|
|
81
|
+
const findings = [];
|
|
82
|
+
const collision = severity('collision');
|
|
83
|
+
const unscoped = severity('unscoped');
|
|
84
|
+
const sharedDirectory = severity('shared-directory');
|
|
85
|
+
for (const wave of report.waves) {
|
|
86
|
+
if (collision !== null) {
|
|
87
|
+
for (const c of wave.collisions) {
|
|
88
|
+
findings.push({
|
|
89
|
+
rule: 'collision',
|
|
90
|
+
severity: collision,
|
|
91
|
+
message: `"${c.patterns[1]}" overlaps ${label(c.a)}'s "${c.patterns[0]}" ${where(wave.wave)}; both cover ${c.witness}`,
|
|
92
|
+
file: c.b.file,
|
|
93
|
+
line: lineOfField(c.b, 'affectedFiles') + 1,
|
|
94
|
+
brief: c.b.id ?? undefined,
|
|
95
|
+
hint: `run them in different waves, make one depend on the other, or narrow a scope`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (sharedDirectory !== null) {
|
|
100
|
+
for (const s of wave.shared) {
|
|
101
|
+
findings.push({
|
|
102
|
+
rule: 'shared-directory',
|
|
103
|
+
severity: sharedDirectory,
|
|
104
|
+
message: `writes into ${s.directory}/, as ${label(s.a)} does ${where(wave.wave)}`,
|
|
105
|
+
file: s.b.file,
|
|
106
|
+
line: lineOfField(s.b, 'affectedFiles') + 1,
|
|
107
|
+
brief: s.b.id ?? undefined,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (unscoped !== null) {
|
|
112
|
+
for (const brief of wave.unscoped) {
|
|
113
|
+
findings.push({
|
|
114
|
+
rule: 'unscoped',
|
|
115
|
+
severity: unscoped,
|
|
116
|
+
message: `declares no affectedFiles, so it cannot be checked against the ${wave.briefs.length - 1} other brief(s) ${where(wave.wave)}`,
|
|
117
|
+
file: brief.file,
|
|
118
|
+
line: 1,
|
|
119
|
+
brief: brief.id ?? undefined,
|
|
120
|
+
hint: 'list the files or globs this round writes under "affectedFiles"',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=collisions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collisions.js","sourceRoot":"","sources":["../src/collisions.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The collision matrix: which briefs scheduled to run side by side declare\n * scopes that can name the same file.\n *\n * Scopes are compared as globs, not as strings. `src/auth/**` and\n * `src/**\\/session.ts` share no prefix a string comparison would see and both\n * cover `src/auth/session.ts`; the intersection finds that, and names the file.\n * A brief that declares no scope cannot be proven apart from anything, so it\n * is reported as unscoped rather than silently counted as safe.\n */\n\nimport type { Brief } from './brief.js';\nimport { lineOfField } from './brief.js';\nimport type { Corpus } from './corpus.js';\nimport { type Glob, globBase, intersectGlobs, parseGlob } from './glob.js';\nimport { severityOf } from './lint.js';\nimport { COLLISION_RULES, type RuleInfo } from './rules.js';\nimport type { Finding, Severity } from './types.js';\n\nexport interface Collision {\n readonly a: Brief;\n readonly b: Brief;\n readonly patterns: readonly [string, string];\n /** A path both scopes cover. */\n readonly witness: string;\n}\n\nexport interface SharedDirectory {\n readonly a: Brief;\n readonly b: Brief;\n readonly directory: string;\n}\n\nexport interface WaveMatrix {\n /** `null` when every live brief is compared regardless of wave. */\n readonly wave: number | null;\n readonly briefs: readonly Brief[];\n readonly collisions: readonly Collision[];\n readonly shared: readonly SharedDirectory[];\n readonly unscoped: readonly Brief[];\n}\n\nexport interface CollisionReport {\n readonly waves: readonly WaveMatrix[];\n /** Live briefs with no wave, which the matrix does not place. */\n readonly unscheduled: readonly Brief[];\n}\n\nexport interface CollisionOptions {\n /** Compare every live brief with every other, whatever its wave. */\n readonly all?: boolean;\n /** Tracked files, which tell a literal file path from a directory. */\n readonly repoFiles?: readonly string[] | null;\n}\n\nfunction scopes(brief: Brief, isFile: ((path: string) => boolean) | undefined): { pattern: string; glob: Glob }[] {\n return brief.affectedFiles.flatMap((pattern) => {\n const parsed = parseGlob(pattern, { isFile });\n return parsed.ok ? [{ pattern, glob: parsed.glob }] : [];\n });\n}\n\nfunction matrix(wave: number | null, briefs: readonly Brief[], isFile: ((path: string) => boolean) | undefined): WaveMatrix {\n const collisions: Collision[] = [];\n const shared: SharedDirectory[] = [];\n const scoped = briefs.map((brief) => ({ brief, scopes: scopes(brief, isFile) }));\n for (let i = 0; i < scoped.length; i += 1) {\n for (let j = i + 1; j < scoped.length; j += 1) {\n const left = scoped[i] as (typeof scoped)[number];\n const right = scoped[j] as (typeof scoped)[number];\n const found: Collision[] = [];\n for (const x of left.scopes) {\n for (const y of right.scopes) {\n const witness = intersectGlobs(x.glob, y.glob);\n if (witness !== null) found.push({ a: left.brief, b: right.brief, patterns: [x.pattern, y.pattern], witness });\n }\n }\n collisions.push(...found);\n if (found.length > 0) continue;\n const leftDirs = new Set(left.scopes.map((s) => globBase(s.glob)).filter((d) => d !== ''));\n const common = [...new Set(right.scopes.map((s) => globBase(s.glob)))].filter((d) => leftDirs.has(d)).sort();\n for (const directory of common) shared.push({ a: left.brief, b: right.brief, directory });\n }\n }\n const unscoped = briefs.length > 1 ? scoped.filter((s) => s.scopes.length === 0).map((s) => s.brief) : [];\n return { wave, briefs, collisions, shared, unscoped };\n}\n\nexport function collisions(corpus: Corpus, options: CollisionOptions = {}): CollisionReport {\n const files = options.repoFiles === undefined || options.repoFiles === null ? null : new Set(options.repoFiles);\n const isFile = files === null ? undefined : (path: string): boolean => files.has(path);\n if (options.all === true) return { waves: [matrix(null, corpus.live, isFile)], unscheduled: [] };\n const byWave = new Map<number, Brief[]>();\n const unscheduled: Brief[] = [];\n for (const brief of corpus.live) {\n if (brief.wave === null) unscheduled.push(brief);\n else byWave.set(brief.wave, [...(byWave.get(brief.wave) ?? []), brief]);\n }\n const waves = [...byWave.keys()].sort((a, b) => a - b).map((wave) => matrix(wave, byWave.get(wave) as Brief[], isFile));\n return { waves, unscheduled };\n}\n\nfunction label(brief: Brief): string {\n return brief.id ?? brief.name;\n}\n\nfunction where(wave: number | null): string {\n return wave === null ? 'among the live briefs' : `in wave ${wave}`;\n}\n\n/**\n * The report as findings. One collision is one finding, placed on the later\n * brief of the pair, which is usually the one still being written.\n */\nexport function collisionFindings(corpus: Corpus, report: CollisionReport): Finding[] {\n const severity = (id: string): Severity | null => {\n const rule = COLLISION_RULES.find((r) => r.id === id) as RuleInfo;\n const setting = severityOf(corpus, id, rule.severity);\n return setting === 'off' ? null : setting;\n };\n const findings: Finding[] = [];\n const collision = severity('collision');\n const unscoped = severity('unscoped');\n const sharedDirectory = severity('shared-directory');\n for (const wave of report.waves) {\n if (collision !== null) {\n for (const c of wave.collisions) {\n findings.push({\n rule: 'collision',\n severity: collision,\n message: `\"${c.patterns[1]}\" overlaps ${label(c.a)}'s \"${c.patterns[0]}\" ${where(wave.wave)}; both cover ${c.witness}`,\n file: c.b.file,\n line: lineOfField(c.b, 'affectedFiles') + 1,\n brief: c.b.id ?? undefined,\n hint: `run them in different waves, make one depend on the other, or narrow a scope`,\n });\n }\n }\n if (sharedDirectory !== null) {\n for (const s of wave.shared) {\n findings.push({\n rule: 'shared-directory',\n severity: sharedDirectory,\n message: `writes into ${s.directory}/, as ${label(s.a)} does ${where(wave.wave)}`,\n file: s.b.file,\n line: lineOfField(s.b, 'affectedFiles') + 1,\n brief: s.b.id ?? undefined,\n });\n }\n }\n if (unscoped !== null) {\n for (const brief of wave.unscoped) {\n findings.push({\n rule: 'unscoped',\n severity: unscoped,\n message: `declares no affectedFiles, so it cannot be checked against the ${wave.briefs.length - 1} other brief(s) ${where(wave.wave)}`,\n file: brief.file,\n line: 1,\n brief: brief.id ?? undefined,\n hint: 'list the files or globs this round writes under \"affectedFiles\"',\n });\n }\n }\n }\n return findings;\n}\n"]}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration: what it may say, what it says when it says nothing, and how a
|
|
3
|
+
* file becomes a `Config`.
|
|
4
|
+
*
|
|
5
|
+
* Every repository that already keeps briefs keeps them its own way - where
|
|
6
|
+
* they live, what the sections are called, which word means "done" - so the
|
|
7
|
+
* conventions are data here rather than code. The defaults describe a brief
|
|
8
|
+
* with the four parts every round needs: an intent, a negative scope, what the
|
|
9
|
+
* round is not empowered to touch, and the invariants that prove it is done.
|
|
10
|
+
*
|
|
11
|
+
* A configuration that does not load stops the run. A typo that silently
|
|
12
|
+
* falls back to defaults produces a clean-looking report about the wrong
|
|
13
|
+
* rules, which is worse than no report.
|
|
14
|
+
*/
|
|
15
|
+
import { type Schema } from './schema.js';
|
|
16
|
+
import type { SeveritySetting } from './types.js';
|
|
17
|
+
export interface SectionRule {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly aliases: readonly string[];
|
|
20
|
+
/** Literal text the section must contain, compared exactly. */
|
|
21
|
+
readonly mustContain: readonly string[];
|
|
22
|
+
/** The section must hold at least one task item. */
|
|
23
|
+
readonly checklist: boolean;
|
|
24
|
+
readonly optional: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface PluginReference {
|
|
27
|
+
readonly module: string;
|
|
28
|
+
readonly options: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface Config {
|
|
31
|
+
/** Directory of live briefs, relative to the root. */
|
|
32
|
+
readonly briefs: string;
|
|
33
|
+
/** Directory of archived briefs, relative to the root. */
|
|
34
|
+
readonly archive: string;
|
|
35
|
+
/** Glob a file name must match to be a brief. */
|
|
36
|
+
readonly files: string;
|
|
37
|
+
/** File-name globs that are never briefs, such as an index. */
|
|
38
|
+
readonly exclude: readonly string[];
|
|
39
|
+
/** A template for `new`, relative to the root; `null` builds one from `sections`. */
|
|
40
|
+
readonly template: string | null;
|
|
41
|
+
readonly id: {
|
|
42
|
+
readonly source: 'filename' | 'frontmatter';
|
|
43
|
+
readonly separator: string;
|
|
44
|
+
readonly digits: number;
|
|
45
|
+
};
|
|
46
|
+
readonly status: {
|
|
47
|
+
readonly field: string | null;
|
|
48
|
+
readonly draft: string | null;
|
|
49
|
+
readonly active: string;
|
|
50
|
+
readonly archived: string;
|
|
51
|
+
};
|
|
52
|
+
readonly sections: readonly SectionRule[];
|
|
53
|
+
readonly sectionOrder: boolean;
|
|
54
|
+
readonly types: Readonly<Record<string, readonly SectionRule[]>>;
|
|
55
|
+
readonly placeholders: readonly string[];
|
|
56
|
+
readonly fields: readonly string[];
|
|
57
|
+
readonly archiving: {
|
|
58
|
+
readonly tasks: 'all' | readonly string[];
|
|
59
|
+
readonly dispositions: readonly string[];
|
|
60
|
+
readonly banner: readonly string[];
|
|
61
|
+
readonly rewriteLinks: boolean;
|
|
62
|
+
readonly freeze: boolean;
|
|
63
|
+
readonly base: string | null;
|
|
64
|
+
};
|
|
65
|
+
readonly rules: Readonly<Record<string, SeveritySetting>>;
|
|
66
|
+
readonly plugins: readonly PluginReference[];
|
|
67
|
+
}
|
|
68
|
+
export declare const CONFIG_FILES: readonly string[];
|
|
69
|
+
/** The holes a banner line may use. */
|
|
70
|
+
export declare const BANNER_PLACEHOLDERS: readonly string[];
|
|
71
|
+
export declare const SCHEMA_URL = "https://raw.githubusercontent.com/DescentVTT/spec-brief/main/schema.json";
|
|
72
|
+
export declare const CONFIG_SCHEMA: Schema;
|
|
73
|
+
export declare const DEFAULT_CONFIG: Config;
|
|
74
|
+
export declare class ConfigError extends Error {
|
|
75
|
+
readonly problems: readonly string[];
|
|
76
|
+
constructor(file: string, problems: readonly string[]);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Builds a configuration from parsed JSON. Objects merge one level deep over
|
|
80
|
+
* the defaults; lists and `types` replace them, because a repository that
|
|
81
|
+
* names its own sections means those sections and not the defaults as well.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveConfig(raw: unknown, file?: string): Config;
|
|
84
|
+
/** Parses the text of a configuration file. */
|
|
85
|
+
export declare function parseConfig(text: string, file: string): Config;
|
|
86
|
+
/**
|
|
87
|
+
* The configuration file for a directory: the nearest one at or above it.
|
|
88
|
+
* `exists` is injected so discovery needs no filesystem of its own.
|
|
89
|
+
*/
|
|
90
|
+
export declare function locateConfig(start: string, exists: (path: string) => Promise<boolean>): Promise<string | undefined>;
|
|
91
|
+
/** The JSON Schema published as `schema.json`. */
|
|
92
|
+
export declare function configJsonSchema(): Record<string, unknown>;
|
|
93
|
+
/** The configuration `init` writes: every default spelled out, so it can be edited rather than looked up. */
|
|
94
|
+
export declare function initialConfig(briefs: string, archive: string): Record<string, unknown>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration: what it may say, what it says when it says nothing, and how a
|
|
3
|
+
* file becomes a `Config`.
|
|
4
|
+
*
|
|
5
|
+
* Every repository that already keeps briefs keeps them its own way - where
|
|
6
|
+
* they live, what the sections are called, which word means "done" - so the
|
|
7
|
+
* conventions are data here rather than code. The defaults describe a brief
|
|
8
|
+
* with the four parts every round needs: an intent, a negative scope, what the
|
|
9
|
+
* round is not empowered to touch, and the invariants that prove it is done.
|
|
10
|
+
*
|
|
11
|
+
* A configuration that does not load stops the run. A typo that silently
|
|
12
|
+
* falls back to defaults produces a clean-looking report about the wrong
|
|
13
|
+
* rules, which is worse than no report.
|
|
14
|
+
*/
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { parseGlob } from './glob.js';
|
|
17
|
+
import { normalisePath } from './links.js';
|
|
18
|
+
import { toJsonSchema, validate } from './schema.js';
|
|
19
|
+
import { templateHoles } from './text.js';
|
|
20
|
+
export const CONFIG_FILES = ['.spec-brief.json', 'spec-brief.json'];
|
|
21
|
+
/** The holes a banner line may use. */
|
|
22
|
+
export const BANNER_PLACEHOLDERS = [
|
|
23
|
+
'date',
|
|
24
|
+
'summary',
|
|
25
|
+
'pr',
|
|
26
|
+
'commit',
|
|
27
|
+
'diffstat',
|
|
28
|
+
'links',
|
|
29
|
+
'id',
|
|
30
|
+
'title',
|
|
31
|
+
'author',
|
|
32
|
+
];
|
|
33
|
+
export const SCHEMA_URL = 'https://raw.githubusercontent.com/DescentVTT/spec-brief/main/schema.json';
|
|
34
|
+
const SEVERITY = { type: 'string', enum: ['off', 'note', 'warning', 'error'] };
|
|
35
|
+
const STRINGS = { type: 'array', items: { type: 'string', minLength: 1 } };
|
|
36
|
+
const SECTION = {
|
|
37
|
+
type: 'anyOf',
|
|
38
|
+
description: 'A section name, or a section with aliases and content rules.',
|
|
39
|
+
options: [
|
|
40
|
+
{ type: 'string', minLength: 1 },
|
|
41
|
+
{
|
|
42
|
+
type: 'object',
|
|
43
|
+
required: ['name'],
|
|
44
|
+
properties: {
|
|
45
|
+
name: { type: 'string', minLength: 1, description: 'The heading text, compared without case or punctuation.' },
|
|
46
|
+
aliases: { ...STRINGS, description: 'Other headings that fill this section.' },
|
|
47
|
+
mustContain: { ...STRINGS, description: 'Literal text the section must contain.' },
|
|
48
|
+
checklist: { type: 'boolean', description: 'The section must hold at least one "- [ ]" task item.' },
|
|
49
|
+
optional: { type: 'boolean', description: 'Checked when present, not required.' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
export const CONFIG_SCHEMA = {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
$schema: { type: 'string' },
|
|
58
|
+
briefs: { type: 'string', minLength: 1, description: 'Directory of live briefs, relative to this file.' },
|
|
59
|
+
archive: { type: 'string', minLength: 1, description: 'Directory of archived briefs, relative to this file.' },
|
|
60
|
+
files: { type: 'string', minLength: 1, description: 'Glob a file name must match to be a brief.' },
|
|
61
|
+
exclude: { ...STRINGS, description: 'File-name globs that are never briefs.' },
|
|
62
|
+
template: { type: 'string', nullable: true, description: 'Template file for "new"; null builds one from the sections.' },
|
|
63
|
+
id: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
source: { type: 'string', enum: ['filename', 'frontmatter'], description: 'Where a brief id comes from.' },
|
|
67
|
+
separator: { type: 'string', minLength: 1, description: 'Separates the id from the slug in a file name.' },
|
|
68
|
+
digits: { type: 'integer', minimum: 1, maximum: 12, description: 'Width of an allocated numeric id.' },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
status: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
field: { type: 'string', nullable: true, description: 'Front-matter key holding the status; null uses location only.' },
|
|
75
|
+
draft: { type: 'string', nullable: true, description: 'The word for a brief still being written, or null.' },
|
|
76
|
+
active: { type: 'string', minLength: 1, description: 'The word for a live brief.' },
|
|
77
|
+
archived: { type: 'string', minLength: 1, description: 'The word for an archived brief.' },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
sections: { type: 'array', items: SECTION, description: 'Sections every live brief carries.' },
|
|
81
|
+
sectionOrder: { type: 'boolean', description: 'Sections must appear in the order listed.' },
|
|
82
|
+
types: {
|
|
83
|
+
type: 'map',
|
|
84
|
+
description: 'Brief types, each with the sections it adds.',
|
|
85
|
+
values: { type: 'object', properties: { sections: { type: 'array', items: SECTION } } },
|
|
86
|
+
},
|
|
87
|
+
placeholders: { ...STRINGS, description: 'Words that mark a section as unwritten.' },
|
|
88
|
+
fields: { ...STRINGS, description: 'Front-matter keys this repository uses beyond the built-in ones.' },
|
|
89
|
+
archiving: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
tasks: {
|
|
93
|
+
type: 'anyOf',
|
|
94
|
+
description: '"all", or the sections whose task items must be closed before archiving.',
|
|
95
|
+
options: [{ type: 'string', enum: ['all'] }, STRINGS],
|
|
96
|
+
},
|
|
97
|
+
dispositions: { ...STRINGS, description: 'Text under an open box that closes it without a tick.' },
|
|
98
|
+
banner: {
|
|
99
|
+
type: 'array',
|
|
100
|
+
items: { type: 'string' },
|
|
101
|
+
description: 'Lines of the frozen banner; a line with an empty {placeholder} is left out, and an empty line separates paragraphs.',
|
|
102
|
+
},
|
|
103
|
+
rewriteLinks: { type: 'boolean', description: 'Rewrite relative links so they resolve from the new directory.' },
|
|
104
|
+
freeze: { type: 'boolean', description: 'Record a content hash so later edits are caught.' },
|
|
105
|
+
base: { type: 'string', nullable: true, description: 'Branch the diff is measured from, such as "main".' },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
rules: { type: 'map', values: SEVERITY, description: 'Severity per rule id.' },
|
|
109
|
+
plugins: {
|
|
110
|
+
type: 'array',
|
|
111
|
+
description: 'Modules that contribute rules.',
|
|
112
|
+
items: {
|
|
113
|
+
type: 'anyOf',
|
|
114
|
+
options: [
|
|
115
|
+
{ type: 'string', minLength: 1 },
|
|
116
|
+
{
|
|
117
|
+
type: 'object',
|
|
118
|
+
required: ['module'],
|
|
119
|
+
properties: { module: { type: 'string', minLength: 1 }, options: { type: 'any' } },
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
function section(name, extra = {}) {
|
|
127
|
+
return { name, aliases: [], mustContain: [], checklist: false, optional: false, ...extra };
|
|
128
|
+
}
|
|
129
|
+
export const DEFAULT_CONFIG = {
|
|
130
|
+
briefs: 'briefs',
|
|
131
|
+
archive: 'briefs/archive',
|
|
132
|
+
files: '[0-9]*.md',
|
|
133
|
+
exclude: [],
|
|
134
|
+
template: null,
|
|
135
|
+
id: { source: 'filename', separator: '_', digits: 3 },
|
|
136
|
+
status: { field: 'status', draft: 'draft', active: 'active', archived: 'archived' },
|
|
137
|
+
sections: [
|
|
138
|
+
section('Intent', { aliases: ["Commander's Intent", 'Mission', 'Objective'] }),
|
|
139
|
+
section('Negative Scope', {
|
|
140
|
+
aliases: ['Out of Scope', 'Non-Goals', 'Not in Scope', 'What this round is NOT', 'What this brief does NOT do'],
|
|
141
|
+
}),
|
|
142
|
+
section('Not Empowered', { aliases: ['Non-Empowerment', 'Non-Empowerment List'], optional: true }),
|
|
143
|
+
section('Invariants', { aliases: ['Invariant Checklist', 'Definition of Done'], checklist: true }),
|
|
144
|
+
],
|
|
145
|
+
sectionOrder: false,
|
|
146
|
+
types: {
|
|
147
|
+
feature: [section('Acceptance Criteria', { checklist: true })],
|
|
148
|
+
defect: [section('The Defect, Measured', { aliases: ['Reproduction'] })],
|
|
149
|
+
refactor: [],
|
|
150
|
+
chore: [],
|
|
151
|
+
},
|
|
152
|
+
placeholders: ['TBD', 'TBA', 'TODO', 'FIXME', 'XXX', '???', '...', '\u2026'],
|
|
153
|
+
fields: [],
|
|
154
|
+
archiving: {
|
|
155
|
+
tasks: 'all',
|
|
156
|
+
dispositions: ['**Delegated', '**Accepted debt', '**Rejected'],
|
|
157
|
+
banner: [
|
|
158
|
+
'**Archived {date}.**',
|
|
159
|
+
'{summary}',
|
|
160
|
+
'Merged in pull request {pr}.',
|
|
161
|
+
'Recorded at commit `{commit}`: {diffstat}.',
|
|
162
|
+
'{links}',
|
|
163
|
+
'The body below describes the tree before execution and is not maintained.',
|
|
164
|
+
],
|
|
165
|
+
rewriteLinks: true,
|
|
166
|
+
freeze: true,
|
|
167
|
+
base: null,
|
|
168
|
+
},
|
|
169
|
+
rules: {},
|
|
170
|
+
plugins: [],
|
|
171
|
+
};
|
|
172
|
+
function sectionFrom(raw) {
|
|
173
|
+
if (typeof raw === 'string')
|
|
174
|
+
return section(raw);
|
|
175
|
+
const r = raw;
|
|
176
|
+
return section(r['name'], {
|
|
177
|
+
aliases: r['aliases'] ?? [],
|
|
178
|
+
mustContain: r['mustContain'] ?? [],
|
|
179
|
+
checklist: r['checklist'] ?? false,
|
|
180
|
+
optional: r['optional'] ?? false,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function pluginFrom(raw) {
|
|
184
|
+
if (typeof raw === 'string')
|
|
185
|
+
return { module: raw, options: undefined };
|
|
186
|
+
const r = raw;
|
|
187
|
+
return { module: r['module'], options: r['options'] };
|
|
188
|
+
}
|
|
189
|
+
export class ConfigError extends Error {
|
|
190
|
+
problems;
|
|
191
|
+
constructor(file, problems) {
|
|
192
|
+
super(`${file}: ${problems.join('; ')}`);
|
|
193
|
+
this.name = 'ConfigError';
|
|
194
|
+
this.problems = problems;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Builds a configuration from parsed JSON. Objects merge one level deep over
|
|
199
|
+
* the defaults; lists and `types` replace them, because a repository that
|
|
200
|
+
* names its own sections means those sections and not the defaults as well.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveConfig(raw, file = 'configuration') {
|
|
203
|
+
const problems = validate(CONFIG_SCHEMA, raw);
|
|
204
|
+
if (problems.length > 0)
|
|
205
|
+
throw new ConfigError(file, problems);
|
|
206
|
+
const r = raw;
|
|
207
|
+
const d = DEFAULT_CONFIG;
|
|
208
|
+
const merged = (key, base) => ({ ...base, ...(r[key] ?? {}) });
|
|
209
|
+
const archiving = merged('archiving', d.archiving);
|
|
210
|
+
const config = {
|
|
211
|
+
briefs: r['briefs'] ?? d.briefs,
|
|
212
|
+
archive: r['archive'] ?? (r['briefs'] === undefined ? d.archive : `${r['briefs']}/archive`),
|
|
213
|
+
files: r['files'] ?? d.files,
|
|
214
|
+
exclude: r['exclude'] ?? d.exclude,
|
|
215
|
+
template: r['template'] === undefined ? d.template : r['template'],
|
|
216
|
+
id: merged('id', d.id),
|
|
217
|
+
status: merged('status', d.status),
|
|
218
|
+
sections: r['sections'] === undefined ? d.sections : r['sections'].map(sectionFrom),
|
|
219
|
+
sectionOrder: r['sectionOrder'] ?? d.sectionOrder,
|
|
220
|
+
types: r['types'] === undefined
|
|
221
|
+
? d.types
|
|
222
|
+
: Object.fromEntries(Object.entries(r['types']).map(([name, type]) => [
|
|
223
|
+
name,
|
|
224
|
+
(type['sections'] ?? []).map(sectionFrom),
|
|
225
|
+
])),
|
|
226
|
+
placeholders: r['placeholders'] ?? d.placeholders,
|
|
227
|
+
fields: r['fields'] ?? d.fields,
|
|
228
|
+
archiving,
|
|
229
|
+
rules: { ...d.rules, ...(r['rules'] ?? {}) },
|
|
230
|
+
plugins: (r['plugins'] ?? []).map(pluginFrom),
|
|
231
|
+
};
|
|
232
|
+
const semantic = checkConfig(config);
|
|
233
|
+
if (semantic.length > 0)
|
|
234
|
+
throw new ConfigError(file, semantic);
|
|
235
|
+
return config;
|
|
236
|
+
}
|
|
237
|
+
/** What the schema cannot say: relationships between values. */
|
|
238
|
+
function checkConfig(config) {
|
|
239
|
+
const problems = [];
|
|
240
|
+
const inside = (key, path) => {
|
|
241
|
+
try {
|
|
242
|
+
return normalisePath(path);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
problems.push(`"${key}" must be a directory inside the repository, not "${path}"`);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const briefs = inside('briefs', config.briefs);
|
|
250
|
+
const archive = inside('archive', config.archive);
|
|
251
|
+
if (briefs !== null && briefs === archive)
|
|
252
|
+
problems.push('"briefs" and "archive" must be different directories');
|
|
253
|
+
const words = [config.status.draft, config.status.active, config.status.archived].filter((w) => w !== null);
|
|
254
|
+
if (new Set(words.map((w) => w.toLowerCase())).size !== words.length) {
|
|
255
|
+
problems.push('the status words for draft, active and archived must differ');
|
|
256
|
+
}
|
|
257
|
+
// A pattern that does not parse would match nothing, and a run over no briefs reads as clean.
|
|
258
|
+
for (const [key, pattern] of [['files', config.files], ...config.exclude.map((e) => ['exclude', e])]) {
|
|
259
|
+
const parsed = parseGlob(pattern);
|
|
260
|
+
if (!parsed.ok)
|
|
261
|
+
problems.push(`"${key}" pattern "${pattern}": ${parsed.error}`);
|
|
262
|
+
}
|
|
263
|
+
// An unknown hole is never filled, and a line with an unfilled hole is left
|
|
264
|
+
// out: a misspelt placeholder would silently drop its whole line.
|
|
265
|
+
for (const line of config.archiving.banner) {
|
|
266
|
+
for (const hole of templateHoles(line)) {
|
|
267
|
+
if (!BANNER_PLACEHOLDERS.includes(hole)) {
|
|
268
|
+
problems.push(`"archiving.banner" uses {${hole}}; the placeholders are ${BANNER_PLACEHOLDERS.map((p) => `{${p}}`).join(', ')}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return problems;
|
|
273
|
+
}
|
|
274
|
+
/** Parses the text of a configuration file. */
|
|
275
|
+
export function parseConfig(text, file) {
|
|
276
|
+
let raw;
|
|
277
|
+
try {
|
|
278
|
+
raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
throw new ConfigError(file, [`is not valid JSON (${error.message})`]);
|
|
282
|
+
}
|
|
283
|
+
return resolveConfig(raw, file);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The configuration file for a directory: the nearest one at or above it.
|
|
287
|
+
* `exists` is injected so discovery needs no filesystem of its own.
|
|
288
|
+
*/
|
|
289
|
+
export async function locateConfig(start, exists) {
|
|
290
|
+
let directory = start;
|
|
291
|
+
// Bounded by the depth of the path: each pass moves one directory up.
|
|
292
|
+
for (let depth = 0; depth < 256; depth += 1) {
|
|
293
|
+
const found = [];
|
|
294
|
+
for (const name of CONFIG_FILES) {
|
|
295
|
+
const candidate = join(directory, name);
|
|
296
|
+
if (await exists(candidate))
|
|
297
|
+
found.push(candidate);
|
|
298
|
+
}
|
|
299
|
+
if (found.length > 1) {
|
|
300
|
+
throw new ConfigError(directory, [`holds both ${CONFIG_FILES.join(' and ')}; keep one`]);
|
|
301
|
+
}
|
|
302
|
+
if (found[0] !== undefined)
|
|
303
|
+
return found[0];
|
|
304
|
+
const parent = dirname(directory);
|
|
305
|
+
if (parent === directory)
|
|
306
|
+
return undefined;
|
|
307
|
+
directory = parent;
|
|
308
|
+
}
|
|
309
|
+
/* v8 ignore next -- no path is 256 directories deep; the bound exists so the loop ends by construction. */
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
/** The JSON Schema published as `schema.json`. */
|
|
313
|
+
export function configJsonSchema() {
|
|
314
|
+
return {
|
|
315
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
316
|
+
$id: SCHEMA_URL,
|
|
317
|
+
title: 'spec-brief configuration',
|
|
318
|
+
...toJsonSchema(CONFIG_SCHEMA),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/** The configuration `init` writes: every default spelled out, so it can be edited rather than looked up. */
|
|
322
|
+
export function initialConfig(briefs, archive) {
|
|
323
|
+
const d = DEFAULT_CONFIG;
|
|
324
|
+
const sectionJson = (s) => {
|
|
325
|
+
const out = { name: s.name };
|
|
326
|
+
if (s.aliases.length > 0)
|
|
327
|
+
out['aliases'] = s.aliases;
|
|
328
|
+
if (s.mustContain.length > 0)
|
|
329
|
+
out['mustContain'] = s.mustContain;
|
|
330
|
+
if (s.checklist)
|
|
331
|
+
out['checklist'] = true;
|
|
332
|
+
if (s.optional)
|
|
333
|
+
out['optional'] = true;
|
|
334
|
+
return Object.keys(out).length === 1 ? s.name : out;
|
|
335
|
+
};
|
|
336
|
+
return {
|
|
337
|
+
$schema: SCHEMA_URL,
|
|
338
|
+
briefs,
|
|
339
|
+
archive,
|
|
340
|
+
files: d.files,
|
|
341
|
+
id: d.id,
|
|
342
|
+
status: d.status,
|
|
343
|
+
sections: d.sections.map(sectionJson),
|
|
344
|
+
types: Object.fromEntries(Object.entries(d.types).map(([k, v]) => [k, { sections: v.map(sectionJson) }])),
|
|
345
|
+
archiving: {
|
|
346
|
+
tasks: d.archiving.tasks,
|
|
347
|
+
dispositions: d.archiving.dispositions,
|
|
348
|
+
banner: d.archiving.banner,
|
|
349
|
+
base: d.archiving.base,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
//# sourceMappingURL=config.js.map
|