dw-sdlc 0.5.0 → 0.6.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.
@@ -1,56 +1,70 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/plugins/references/config/references.schema.json",
3
+ "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/packages/dw-sdlc/config/references.schema.json",
4
4
  "title": "references",
5
- "description": "The `references` key of `.ai/agentic.config.json`: the repositories an agent reads to see how a problem was solved before, each brought to its ref under `references/<dir>` by the Sync. Only the committed file is read; the local layer adds no References. Every `dir` is unique in the list, which this schema cannot say and the Sync refuses. A key the Sync refuses syncs nothing.",
6
- "type": "array",
7
- "items": {
8
- "type": "object",
9
- "properties": {
10
- "dir": {
11
- "description": "The one directory under `references/` the clone lives in: letters, digits, `_`, `.` and `-`, not starting with `.` or `-`.",
12
- "type": "string",
13
- "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
14
- },
15
- "repository": {
16
- "description": "A URL `git` can fetch. A `https://github.com/` one fetches with the GitHub CLI's credentials. No whitespace, and no leading `-`, which `git` would read as an option.",
17
- "type": "string",
18
- "pattern": "^[^\\s-]\\S*$"
19
- },
20
- "ref": {
21
- "description": "A branch, whose head the clone moves to, or a tag. `{name}` in it stands for the exact version the root `package.json` pins for package `name`, read through the default catalog of `pnpm-workspace.yaml` when the pin says `catalog:` or `catalog:default`. No whitespace, and no leading `-`.",
22
- "type": "string",
23
- "pattern": "^[^\\s-]\\S*$"
24
- },
25
- "only": {
26
- "description": "Paths the sparse checkout holds beside the repository's top-level files, relative to the clone and inside it; a directory ends in `/`. Absent, the whole tree is checked out. Present, it names at least one path, and a path the ref's tree lacks fails the Reference.",
27
- "type": "array",
28
- "minItems": 1,
29
- "items": {
30
- "type": "string",
31
- "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
32
- }
33
- },
34
- "skip": {
35
- "description": "Paths the sparse checkout leaves out, relative to the clone and inside it. Empty or absent, nothing is left out. With `only`, each lies inside one of its paths, which this schema cannot say and the Sync refuses.",
36
- "type": "array",
37
- "items": {
38
- "type": "string",
39
- "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
40
- }
41
- },
42
- "what": {
43
- "description": "What the Reference is.",
44
- "type": "string",
45
- "minLength": 1
46
- },
47
- "when": {
48
- "description": "When an agent reaches for it.",
49
- "type": "string",
50
- "minLength": 1
51
- }
5
+ "description": "`.ai/references.json`: the repositories an agent reads to see how a problem was solved before, each brought to its ref under `references/<dir>` by the Sync. Only the committed file is read. A `references` key in `.ai/agentic.config.json` is refused: `dw-sdlc references setup` moves it here. A file the Sync refuses syncs nothing.",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string"
10
+ },
11
+ "version": {
12
+ "const": 1
52
13
  },
53
- "required": ["dir", "repository", "ref", "what", "when"],
54
- "additionalProperties": false
55
- }
14
+ "references": {
15
+ "description": "The References, in the order the Router lists them. Every `dir` is unique in the list, which this schema cannot say and the Sync refuses.",
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object",
19
+ "properties": {
20
+ "dir": {
21
+ "description": "The one directory under `references/` the clone lives in: letters, digits, `_`, `.` and `-`, not starting with `.` or `-`.",
22
+ "type": "string",
23
+ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]*$"
24
+ },
25
+ "repository": {
26
+ "description": "A URL `git` can fetch. A `https://github.com/` one fetches with the GitHub CLI's credentials. No whitespace, and no leading `-`, which `git` would read as an option.",
27
+ "type": "string",
28
+ "pattern": "^[^\\s-]\\S*$"
29
+ },
30
+ "ref": {
31
+ "description": "A branch, whose head the clone moves to, or a tag. `{name}` in it stands for the exact version the root `package.json` pins for package `name`, read through the default catalog of `pnpm-workspace.yaml` when the pin says `catalog:` or `catalog:default`. No whitespace, and no leading `-`.",
32
+ "type": "string",
33
+ "pattern": "^[^\\s-]\\S*$"
34
+ },
35
+ "only": {
36
+ "description": "Paths the sparse checkout holds beside the repository's top-level files, relative to the clone and inside it; a directory ends in `/`. Absent, the whole tree is checked out. Present, it names at least one path, and a path the ref's tree lacks fails the Reference.",
37
+ "type": "array",
38
+ "minItems": 1,
39
+ "items": {
40
+ "type": "string",
41
+ "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
42
+ }
43
+ },
44
+ "skip": {
45
+ "description": "Paths the sparse checkout leaves out, relative to the clone and inside it. Empty or absent, nothing is left out. With `only`, each lies inside one of its paths, which this schema cannot say and the Sync refuses.",
46
+ "type": "array",
47
+ "items": {
48
+ "type": "string",
49
+ "pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$)).+$"
50
+ }
51
+ },
52
+ "what": {
53
+ "description": "What the Reference is.",
54
+ "type": "string",
55
+ "minLength": 1
56
+ },
57
+ "when": {
58
+ "description": "When an agent reaches for it.",
59
+ "type": "string",
60
+ "minLength": 1
61
+ }
62
+ },
63
+ "required": ["dir", "repository", "ref", "what", "when"],
64
+ "additionalProperties": false
65
+ }
66
+ }
67
+ },
68
+ "required": ["version", "references"],
69
+ "additionalProperties": false
56
70
  }
@@ -100,6 +100,7 @@ function checkPlugin(probe, claude) {
100
100
  };
101
101
  }
102
102
  const version = typeof entry.version === "string" ? entry.version : "?";
103
+ const installPath = typeof entry.installPath === "string" ? entry.installPath : "?";
103
104
  if (entry.enabled !== true) {
104
105
  return {
105
106
  name: "plugin",
@@ -110,7 +111,7 @@ function checkPlugin(probe, claude) {
110
111
  return {
111
112
  name: "plugin",
112
113
  level: "PASS",
113
- detail: `${version} enabled; ${pluginOrigin(probe, claude, String(entry.installPath ?? ""))}`
114
+ detail: `${version} enabled; ${pluginOrigin(probe, claude, installPath)}`
114
115
  };
115
116
  }
116
117
  /** The `version` of the package.json in `dir`, or undefined when there is none. */
@@ -1,12 +1,12 @@
1
- // `dw-sdlc references setup` sets a repository up to declare References: the five files its USAGE names,
2
- // each written only when it would change. Existing content stays: a list gains the entries it lacks at
3
- // its end, nothing is removed or reordered, and a file the step cannot read as it expects is refused.
1
+ // `dw-sdlc references setup` sets a repository up to declare References: the files its USAGE names,
2
+ // each written only when it would change. A list gains the entries it lacks at its end; the one removal
3
+ // is a `references` key moving out of the agentic config. A file it cannot read as expected is refused.
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { parseArgs } from "node:util";
8
- import { appendKey, parseObject, render } from "../json-text.js";
9
- import { CONFIG, readReferences } from "../references-key.js";
8
+ import { parseObject, removeKey, render } from "../json-text.js";
9
+ import { CONFIG, REFERENCES_FILE, leftoverKey, leftoverProblem, parseReferences, readFile } from "../references-key.js";
10
10
  import { renderBlock, withBlock } from "../router-block.js";
11
11
  const ROUTER = "references/AGENTS.md";
12
12
  /** A symlink to the Router, so Claude Code loads it. */
@@ -14,6 +14,8 @@ const ROUTER_LINK = "references/CLAUDE.md";
14
14
  const SETTINGS = ".claude/settings.json";
15
15
  const IGNORE = ".gitignore";
16
16
  const TEMPLATE = join(import.meta.dirname, "..", "..", "templates", "router.md");
17
+ /** Where a file the setup creates points `$schema`: the schema the pinned package installs. */
18
+ const SCHEMA = "../node_modules/dw-sdlc/config/references.schema.json";
17
19
  /** A clone's own agent files at any depth; Claude Code matches the globs against absolute paths. */
18
20
  const EXCLUDES = [
19
21
  "**/references/*/CLAUDE.md",
@@ -33,12 +35,13 @@ const USAGE = `Usage: dw-sdlc references setup [--root <dir>] [--dry-run | --che
33
35
 
34
36
  Sets the repository at --root, or the one the current directory sits in, up to declare References:
35
37
  the Router in ${ROUTER} with its CLAUDE.md symlink and, between its two markers, the table of each
36
- Reference's dir and when from the references key, claudeMdExcludes in ${SETTINGS} for the clones'
37
- own agent files, the ${IGNORE} lines that keep every clone out of git but the Router, and an empty
38
- references key in ${CONFIG}. Every file is written only when it would change, and the text around
39
- the Router's table never; --dry-run reports without writing, and --check does the same and exits 1
40
- when a file would change. Prints one line per file. Exit 1 when a file is refused, 2 on a bad
41
- argument.`;
38
+ Reference's dir and when from ${REFERENCES_FILE}, claudeMdExcludes in ${SETTINGS} for the clones' own agent
39
+ files, the ${IGNORE} lines that keep every clone out of git but the Router, and ${REFERENCES_FILE} itself
40
+ with no References when it is missing. A references key in ${CONFIG} moves into a missing ${REFERENCES_FILE}
41
+ and leaves the config; with the file there too, both stay and the step is refused. Every file is
42
+ written only when it would change, and the text around the Router's table never; --dry-run reports
43
+ without writing, and --check does the same and exits 1 when a file would change. Prints one line
44
+ per file. Exit 1 when a file is refused, 2 on a bad argument.`;
42
45
  function messageOf(error) {
43
46
  return error instanceof Error ? error.message : String(error);
44
47
  }
@@ -116,16 +119,43 @@ function withIgnoreLines(current) {
116
119
  const gap = head !== "" && !ignored ? "\n" : "";
117
120
  return { text: `${head}${gap}${missing.join("\n")}\n` };
118
121
  }
119
- /** The config with an empty `references` key when it has none, inserted so its own layout stays. */
120
- function withEmptyKey(current) {
121
- if (current === undefined) {
122
- return { text: render({ references: [] }) };
122
+ /** The text of a References file the setup creates, declaring `references`. */
123
+ function fileOf(references) {
124
+ return render({ $schema: SCHEMA, version: 1, references });
125
+ }
126
+ /**
127
+ * Where the References come from: a `references` key left in the config moves into a missing file,
128
+ * and a repository with neither gets the file with no References; the file and the key both there
129
+ * are refused, and so is a key the parser refuses.
130
+ */
131
+ function source(root) {
132
+ const key = leftoverKey(root);
133
+ const path = join(root, REFERENCES_FILE);
134
+ if (key === undefined) {
135
+ const reading = readFile(root);
136
+ if ("problem" in reading) {
137
+ // The problem is the file's own, and the Router's line names it; the file stays as it is.
138
+ return { problem: reading.problem, file: { text: readFileSync(path, "utf8") } };
139
+ }
140
+ return {
141
+ references: reading.references,
142
+ file: { text: existsSync(path) ? readFileSync(path, "utf8") : fileOf([]) }
143
+ };
123
144
  }
124
- const config = parseObject(current);
125
- if (config === undefined) {
126
- return { refusal: "is not a JSON object; fix it by hand first" };
145
+ if (existsSync(path)) {
146
+ return { problem: leftoverProblem(true) };
147
+ }
148
+ const parsed = parseReferences(key);
149
+ if ("problem" in parsed) {
150
+ return { problem: `${CONFIG}: ${parsed.problem.path}: ${parsed.problem.message}; fix it by hand first` };
127
151
  }
128
- return { text: Object.hasOwn(config, "references") ? current : appendKey(current, "references", []) };
152
+ return {
153
+ references: parsed.references,
154
+ file: { text: fileOf(key) },
155
+ config: (current) => current === undefined || parseObject(current) === undefined
156
+ ? { refusal: "is not a JSON object; fix it by hand first" }
157
+ : { text: removeKey(current, "references") }
158
+ };
129
159
  }
130
160
  /** The repository `dir` sits in, or `dir` itself outside one. */
131
161
  function toplevel(dir) {
@@ -135,12 +165,12 @@ function toplevel(dir) {
135
165
  function setup(root, dryRun) {
136
166
  const writer = new Writer(root, dryRun);
137
167
  const template = readFileSync(TEMPLATE, "utf8");
138
- const reading = readReferences(root);
168
+ const from = source(root);
139
169
  writer.put(ROUTER, (current) => {
140
- if ("problem" in reading) {
141
- return { refusal: `${reading.problem}; the table comes from the key, so fix it first` };
170
+ if ("problem" in from) {
171
+ return { refusal: `${from.problem}; the table comes from ${REFERENCES_FILE}, so fix it first` };
142
172
  }
143
- const next = withBlock(current ?? template, renderBlock(reading.references ?? []));
173
+ const next = withBlock(current ?? template, renderBlock(from.references ?? []));
144
174
  return "problem" in next ? { refusal: next.problem } : next;
145
175
  });
146
176
  writer.create(ROUTER_LINK, (path) => {
@@ -148,7 +178,10 @@ function setup(root, dryRun) {
148
178
  });
149
179
  writer.put(SETTINGS, withExcludes);
150
180
  writer.put(IGNORE, withIgnoreLines);
151
- writer.put(CONFIG, withEmptyKey);
181
+ writer.put(REFERENCES_FILE, () => ("problem" in from ? (from.file ?? { refusal: from.problem }) : from.file));
182
+ if (!("problem" in from) && from.config !== undefined) {
183
+ writer.put(CONFIG, from.config);
184
+ }
152
185
  return writer.steps;
153
186
  }
154
187
  export function run(argv) {
@@ -1,4 +1,4 @@
1
- // `dw-sdlc references sync`, the Sync: every Reference the committed config declares brought to its ref
1
+ // `dw-sdlc references sync`, the Sync: every Reference `.ai/references.json` declares brought to its ref
2
2
  // under references/<dir>, each clone shallow, blobless, detached and sparse where its entry says. An
3
3
  // entry that fails fails alone, and a repository that declares no References gets no output at all.
4
4
  import { spawnSync } from "node:child_process";
@@ -7,7 +7,7 @@ import { extname, join } from "node:path";
7
7
  import { setTimeout as sleep } from "node:timers/promises";
8
8
  import { parseArgs } from "node:util";
9
9
  import { readPins, resolveRef } from "../pins.js";
10
- import { CONFIG, readReferences } from "../references-key.js";
10
+ import { REFERENCES_FILE, readReferences } from "../references-key.js";
11
11
  import { renderBlock, withBlock } from "../router-block.js";
12
12
  const CLONES = "references";
13
13
  const ROUTER = "references/AGENTS.md";
@@ -22,7 +22,7 @@ export const summary = "bring every declared Reference to its ref under referenc
22
22
  const USAGE = `Usage: dw-sdlc references sync [dir...]
23
23
  or: pnpm references:sync [dir...], in a checkout of dw-sdlc
24
24
 
25
- Brings every Reference the references key of ${CONFIG} declares to its ref under ${CLONES}/<dir>, or
25
+ Brings every Reference ${REFERENCES_FILE} declares to its ref under ${CLONES}/<dir>, or
26
26
  only the given dirs: a missing one cloned shallow, an existing one moved to its branch's head or its
27
27
  tag, holding the top-level files and the paths its only names when it has one, without the paths
28
28
  its skip names. Prints one line per Reference. Exit 1 when any Reference fails, 2 on a bad key,
@@ -304,7 +304,7 @@ export async function run(argv) {
304
304
  const references = reading.references ?? [];
305
305
  const unknown = cli.dirs.filter((dir) => !references.some((reference) => reference.dir === dir));
306
306
  if (unknown.length > 0) {
307
- fail(`not in the references of ${CONFIG}: ${unknown.join(", ")}`);
307
+ fail(`not in ${REFERENCES_FILE}: ${unknown.join(", ")}`);
308
308
  }
309
309
  if (reading.references === undefined) {
310
310
  return 0;
@@ -335,7 +335,7 @@ export async function run(argv) {
335
335
  .map((entry) => `${CLONES}/${entry.name}`)
336
336
  : [];
337
337
  if (stray.length > 0) {
338
- console.log(`Not listed in ${CONFIG}, left alone: ${stray.join(", ")}`);
338
+ console.log(`Not listed in ${REFERENCES_FILE}, left alone: ${stray.join(", ")}`);
339
339
  }
340
340
  return failed ? 1 : 0;
341
341
  }
@@ -5,3 +5,8 @@ export declare function parseObject(text: string): JsonObject | undefined;
5
5
  export declare function render(value: unknown): string;
6
6
  /** `text`, a JSON object, with `key` set to `value` after its last key, two spaces in. */
7
7
  export declare function appendKey(text: string, key: string, value: unknown): string;
8
+ /**
9
+ * `text`, a JSON object, without its top-level `key`, every copy of it when the key repeats; the
10
+ * members around it keep their layout.
11
+ */
12
+ export declare function removeKey(text: string, key: string): string;
package/dist/json-text.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The text of a JSON object file as the setup commands read and write it: parsed only when it holds an
2
- // object, rendered two spaces in, and a key appended so a hand-formatted file keeps its layout.
2
+ // object, rendered two spaces in, and a key appended or removed so a hand-formatted file keeps its layout.
3
3
  export function isObject(value) {
4
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
5
  }
@@ -24,3 +24,76 @@ export function appendKey(text, key, value) {
24
24
  const rendered = JSON.stringify(value, null, 2).replaceAll("\n", "\n ");
25
25
  return `${body}${comma}\n ${JSON.stringify(key)}: ${rendered}\n${text.slice(close)}`;
26
26
  }
27
+ /** The index just past the JSON string that opens at `start`, or the text's end when it never closes. */
28
+ function stringEnd(text, start) {
29
+ let index = start + 1;
30
+ while (index < text.length && text[index] !== '"') {
31
+ index += text[index] === "\\" ? 2 : 1;
32
+ }
33
+ return Math.min(index + 1, text.length);
34
+ }
35
+ /** The start and end of the top-level member `key` in `text`, a JSON object, from its name to its value's end. */
36
+ function memberSpan(text, key) {
37
+ let depth = 0;
38
+ let expectKey = false;
39
+ let start;
40
+ // Just past the last character that is not whitespace: where the member's value ends once a `,` or `}` follows.
41
+ let lastValue = 0;
42
+ for (let index = 0; index < text.length;) {
43
+ const char = text.charAt(index);
44
+ if (char === '"') {
45
+ const end = stringEnd(text, index);
46
+ if (depth === 1 && expectKey) {
47
+ expectKey = false;
48
+ if (start === undefined && JSON.parse(text.slice(index, end)) === key) {
49
+ start = index;
50
+ }
51
+ }
52
+ lastValue = end;
53
+ index = end;
54
+ continue;
55
+ }
56
+ if (char === "{" || char === "[") {
57
+ depth += 1;
58
+ expectKey = char === "{" && depth === 1;
59
+ }
60
+ else if (char === "}" || char === "]") {
61
+ depth -= 1;
62
+ if (depth === 0 && start !== undefined) {
63
+ return { start, end: lastValue };
64
+ }
65
+ }
66
+ else if (char === "," && depth === 1) {
67
+ if (start !== undefined) {
68
+ return { start, end: lastValue };
69
+ }
70
+ expectKey = true;
71
+ }
72
+ if (!/\s/.test(char)) {
73
+ lastValue = index + 1;
74
+ }
75
+ index += 1;
76
+ }
77
+ return undefined;
78
+ }
79
+ /**
80
+ * `text`, a JSON object, without its top-level `key`, every copy of it when the key repeats; the
81
+ * members around it keep their layout.
82
+ */
83
+ export function removeKey(text, key) {
84
+ const span = memberSpan(text, key);
85
+ if (span === undefined) {
86
+ return text;
87
+ }
88
+ return removeKey(withoutSpan(text, span), key);
89
+ }
90
+ /** `text` without the member at `span` and the comma that joins it to its neighbour. */
91
+ function withoutSpan(text, span) {
92
+ const after = text.slice(span.end).match(/^\s*,\s*/);
93
+ if (after !== null) {
94
+ return text.slice(0, span.start) + text.slice(span.end + after[0].length);
95
+ }
96
+ const before = text.slice(0, span.start).match(/,?\s*$/);
97
+ const cut = span.start - (before?.[0].length ?? 0);
98
+ return text.slice(0, cut) + text.slice(span.end);
99
+ }
@@ -1,4 +1,6 @@
1
- /** The committed config, the only file the key is read from. */
1
+ /** The committed file the References are read from; no local layer adds to it. */
2
+ export declare const REFERENCES_FILE = ".ai/references.json";
3
+ /** The committed agentic config: a `references` key in it is refused, so the file stays the one list. */
2
4
  export declare const CONFIG = ".ai/agentic.config.json";
3
5
  export type Reference = {
4
6
  dir: string;
@@ -20,12 +22,29 @@ export declare function parseReferences(value: unknown): {
20
22
  } | {
21
23
  problem: KeyProblem;
22
24
  };
25
+ /** The References the file's parsed `value` declares, or the first problem in it; `""` names its root. */
26
+ export declare function parseFile(value: unknown): {
27
+ references: Reference[];
28
+ } | {
29
+ problem: KeyProblem;
30
+ };
31
+ /** The `references` key the agentic config at `root` still holds, `undefined` without one or the config. */
32
+ export declare function leftoverKey(root: string): unknown;
33
+ /** Why a `references` key left in the config stops a read, by whether the file is there beside it. */
34
+ export declare function leftoverProblem(fileExists: boolean): string;
23
35
  /**
24
- * The References the committed config at `root` declares, `undefined` without the file or the key,
25
- * or the problem that stops the read: the file is not a JSON object, or the key fails the parser.
36
+ * The References the file at `root` declares, `undefined` without the file, or the problem that stops
37
+ * the read: a `references` key left in the agentic config, a file that is not JSON, or one the parser
38
+ * refuses. A config that is not JSON is the guards' to report, not the read's.
26
39
  */
27
40
  export declare function readReferences(root: string): {
28
41
  references: Reference[] | undefined;
29
42
  } | {
30
43
  problem: string;
31
44
  };
45
+ /** The References the file at `root` declares, `undefined` without it, the agentic config unread. */
46
+ export declare function readFile(root: string): {
47
+ references: Reference[] | undefined;
48
+ } | {
49
+ problem: string;
50
+ };
@@ -1,10 +1,13 @@
1
- // The strict parser of the `references` key, the authority at runtime, and the reader of the committed
2
- // config that holds it. `config/references.schema.json` documents the same contract, and
3
- // `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
1
+ // The strict parser of `.ai/references.json`, the authority at runtime, and its reader. The file's
2
+ // `references` key holds the entries. `config/references.schema.json` documents the same contract,
3
+ // and `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- /** The committed config, the only file the key is read from. */
6
+ /** The committed file the References are read from; no local layer adds to it. */
7
+ export const REFERENCES_FILE = ".ai/references.json";
8
+ /** The committed agentic config: a `references` key in it is refused, so the file stays the one list. */
7
9
  export const CONFIG = ".ai/agentic.config.json";
10
+ const FILE_FIELDS = ["$schema", "version", "references"];
8
11
  const FIELDS = ["dir", "repository", "ref", "only", "skip", "what", "when"];
9
12
  const REQUIRED = ["dir", "repository", "ref", "what", "when"];
10
13
  const DIR_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
@@ -119,31 +122,78 @@ export function parseReferences(value) {
119
122
  throw error;
120
123
  }
121
124
  }
122
- /**
123
- * The References the committed config at `root` declares, `undefined` without the file or the key,
124
- * or the problem that stops the read: the file is not a JSON object, or the key fails the parser.
125
- */
126
- export function readReferences(root) {
127
- const path = join(root, CONFIG);
125
+ /** The References the file's parsed `value` declares, or the first problem in it; `""` names its root. */
126
+ export function parseFile(value) {
127
+ if (!isObject(value)) {
128
+ return { problem: { path: "", message: `must be an object, not ${kind(value)}` } };
129
+ }
130
+ const unknown = Object.keys(value).find((field) => !FILE_FIELDS.includes(field));
131
+ if (unknown !== undefined) {
132
+ return { problem: { path: "", message: `unknown key ${JSON.stringify(unknown)}` } };
133
+ }
134
+ const missing = ["version", "references"].find((field) => !Object.hasOwn(value, field));
135
+ if (missing !== undefined) {
136
+ return { problem: { path: "", message: `missing key ${JSON.stringify(missing)}` } };
137
+ }
138
+ if (value.$schema !== undefined && typeof value.$schema !== "string") {
139
+ return { problem: { path: "$schema", message: `must be a string, not ${kind(value.$schema)}` } };
140
+ }
141
+ if (value.version !== 1) {
142
+ return { problem: { path: "version", message: `must be 1, not ${JSON.stringify(value.version)}` } };
143
+ }
144
+ return parseReferences(value.references);
145
+ }
146
+ /** The JSON object at `path`, `undefined` without the file, or why it is not one. */
147
+ function readObject(path, name) {
128
148
  if (!existsSync(path)) {
129
- return { references: undefined };
149
+ return { value: undefined };
130
150
  }
131
- let config;
151
+ let value;
132
152
  try {
133
- config = JSON.parse(readFileSync(path, "utf8"));
153
+ value = JSON.parse(readFileSync(path, "utf8"));
134
154
  }
135
155
  catch (error) {
136
- return { problem: `${CONFIG}: not JSON: ${error instanceof Error ? error.message : String(error)}` };
156
+ return { problem: `${name}: not JSON: ${error instanceof Error ? error.message : String(error)}` };
157
+ }
158
+ return isObject(value) ? { value } : { problem: `${name}: not a JSON object` };
159
+ }
160
+ /** The `references` key the agentic config at `root` still holds, `undefined` without one or the config. */
161
+ export function leftoverKey(root) {
162
+ const read = readObject(join(root, CONFIG), CONFIG);
163
+ return "value" in read && read.value !== undefined && Object.hasOwn(read.value, "references")
164
+ ? read.value.references
165
+ : undefined;
166
+ }
167
+ /** Why a `references` key left in the config stops a read, by whether the file is there beside it. */
168
+ export function leftoverProblem(fileExists) {
169
+ return fileExists
170
+ ? `${CONFIG} has a references key beside ${REFERENCES_FILE}; merge it into the file and remove the key by hand`
171
+ : `${CONFIG}: the references key belongs in ${REFERENCES_FILE}; move it there with \`dw-sdlc references setup\``;
172
+ }
173
+ /**
174
+ * The References the file at `root` declares, `undefined` without the file, or the problem that stops
175
+ * the read: a `references` key left in the agentic config, a file that is not JSON, or one the parser
176
+ * refuses. A config that is not JSON is the guards' to report, not the read's.
177
+ */
178
+ export function readReferences(root) {
179
+ if (leftoverKey(root) !== undefined) {
180
+ return { problem: leftoverProblem(existsSync(join(root, REFERENCES_FILE))) };
137
181
  }
138
- if (!isObject(config)) {
139
- return { problem: `${CONFIG}: not a JSON object` };
182
+ return readFile(root);
183
+ }
184
+ /** The References the file at `root` declares, `undefined` without it, the agentic config unread. */
185
+ export function readFile(root) {
186
+ const read = readObject(join(root, REFERENCES_FILE), REFERENCES_FILE);
187
+ if ("problem" in read) {
188
+ return read;
140
189
  }
141
- if (!Object.hasOwn(config, "references")) {
190
+ if (read.value === undefined) {
142
191
  return { references: undefined };
143
192
  }
144
- const parsed = parseReferences(config.references);
193
+ const parsed = parseFile(read.value);
145
194
  if ("problem" in parsed) {
146
- return { problem: `${CONFIG}: ${parsed.problem.path}: ${parsed.problem.message}` };
195
+ const where = parsed.problem.path === "" ? "" : `${parsed.problem.path}: `;
196
+ return { problem: `${REFERENCES_FILE}: ${where}${parsed.problem.message}` };
147
197
  }
148
198
  return { references: parsed.references };
149
199
  }
@@ -1,5 +1,5 @@
1
1
  import type { Reference } from "./references-key.ts";
2
- /** The block the key gives: one row per Reference in the key's order, or one line when it has none. */
2
+ /** The block the file gives: one row per Reference in the file's order, or one line when it has none. */
3
3
  export declare function renderBlock(references: Reference[]): string;
4
4
  /**
5
5
  * The Router's text with `block` in place of the one between its markers: as it is when the two say
@@ -1,12 +1,12 @@
1
- // The Router's block: a table of each Reference's `dir` and `when`, written from the `references` key
1
+ // The Router's block: a table of each Reference's `dir` and `when`, written from `.ai/references.json`
2
2
  // between two markers. The setup writes it and the Sync checks it; the rest of the Router is the
3
- // author's. A block is in step when its rows say what the key says, not its bytes, which a formatter pads.
4
- const START = "<!-- references:start — written from .ai/agentic.config.json by the setup; edit the key -->";
3
+ // author's. A block is in step when its rows say what the file says, not its bytes, which a formatter pads.
4
+ const START = "<!-- references:start — written from .ai/references.json by the setup; edit the file -->";
5
5
  const END = "<!-- references:end -->";
6
6
  const START_LINE = /^<!-- references:start\b.*-->\s*$/;
7
7
  const END_LINE = /^<!-- references:end -->\s*$/;
8
8
  const NONE = "No Reference is declared yet: `/sdlc-references-add <repository>` adds one.";
9
- /** The block the key gives: one row per Reference in the key's order, or one line when it has none. */
9
+ /** The block the file gives: one row per Reference in the file's order, or one line when it has none. */
10
10
  export function renderBlock(references) {
11
11
  const body = references.length === 0
12
12
  ? [NONE]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, and the Sync and setup of References; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,11 +9,11 @@ file routes reference material only.
9
9
 
10
10
  ## The list
11
11
 
12
- The table below is written from the `references` key of `.ai/agentic.config.json` by the setup, so
13
- edit the key, never the table; the key also holds `what` each Reference is, its repository, its ref
14
- and the paths its clone skips. Before you research, open the clones whose `when` matches the task.
12
+ The table below is written from `.ai/references.json` by the setup, so edit the file, never the
13
+ table; the file also holds `what` each Reference is, its repository, its ref and the paths its clone
14
+ skips. Before you research, open the clones whose `when` matches the task.
15
15
 
16
- <!-- references:start — written from .ai/agentic.config.json by the setup; edit the key -->
16
+ <!-- references:start — written from .ai/references.json by the setup; edit the file -->
17
17
 
18
18
  No Reference is declared yet: `/sdlc-references-add <repository>` adds one.
19
19
  <!-- references:end -->