@warren-dean/code-kit 0.20.0 → 0.21.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.
@@ -16,13 +16,14 @@ function directories(check, files, root) {
16
16
  }
17
17
 
18
18
  /**
19
- * The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. A
19
+ * The first failure as { check, title, output }, or null. `actor` decides `owners`; the lead always may. A
20
20
  * command that isn't installed skips the rest of the check, unless `ifMissing` is "fail", and is
21
21
  * added to `skipped` as { check, command, where }, so a pass never hides a check that didn't run.
22
22
  */
23
23
  function runCheck(check, files, root, actor, skipped) {
24
24
  if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
25
25
  return {
26
+ check: check.name,
26
27
  title: `The ${actor.label} changed ${check.name} files it does not own:`,
27
28
  output: files.join('\n'),
28
29
  };
@@ -39,6 +40,7 @@ function runCheck(check, files, root, actor, skipped) {
39
40
  if (res.status === 127) {
40
41
  if (check.ifMissing === 'fail')
41
42
  return {
43
+ check: check.name,
42
44
  title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
43
45
  };
44
46
  skipped.push({ check: check.name, command, where: where.replace(/^ in /, '') || null });
@@ -46,10 +48,12 @@ function runCheck(check, files, root, actor, skipped) {
46
48
  }
47
49
  if (res.error || res.status === null)
48
50
  return {
51
+ check: check.name,
49
52
  title: `${check.name}: \`${command}\` did not finish${where} (${res.error?.code ?? res.signal}).`,
50
53
  };
51
54
  if (res.status !== 0)
52
55
  return {
56
+ check: check.name,
53
57
  title: `${check.name}: \`${command}\` fails${where}. Fix it:`,
54
58
  output: res.stdout + res.stderr,
55
59
  };
@@ -174,7 +174,7 @@ export function validate(c) {
174
174
  );
175
175
  // A brownfield project may have no specs yet: then there is no spec-check gate.
176
176
  need(c.docs === undefined || isObj(c.docs), '"docs" must be an object');
177
- for (const k of ['specs', 'principles', 'plan', 'decisions'])
177
+ for (const k of ['specs', 'principles', 'plan', 'decisions', 'standards'])
178
178
  need(c.docs?.[k] === undefined || isStr(c.docs[k]), `"docs.${k}" must be a path`);
179
179
  need(isObj(c.lead) && isList(c.lead?.paths), '"lead.paths" must be a list of globs');
180
180
  need(
@@ -5,9 +5,16 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import {
9
+ appliesTo,
10
+ laneLayers,
11
+ projectStandards,
12
+ standardLine,
13
+ standardsFile,
14
+ } from './standards.mjs';
8
15
 
9
16
  /** The version of code-kit's instructions: raised whenever the templates' rules change. */
10
- export const INSTRUCTIONS_VERSION = 5;
17
+ export const INSTRUCTIONS_VERSION = 6;
11
18
 
12
19
  const TEMPLATES = fileURLToPath(new URL('../../templates/', import.meta.url));
13
20
  const BEGIN = (v) => `<!-- code-kit:begin v${v} -->`;
@@ -25,8 +32,20 @@ function fill(text, facts) {
25
32
  .replace(/\n{3,}/g, '\n\n');
26
33
  }
27
34
 
28
- /** What the templates need from the project: everything the config and the repository say. */
29
- function factsOf(root, config) {
35
+ /** The standards that apply (spec 22), or none when the file can't be read. */
36
+ function standardsOf(root, config) {
37
+ try {
38
+ return projectStandards(root, config).standards;
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ /**
45
+ * What the templates need from the project: everything the config and the repository say. With `lane`,
46
+ * the standards are the ones for that lane's layers (STD-3).
47
+ */
48
+ function factsOf(root, config, lane = null) {
30
49
  const delegated = new Set(config.approvals?.delegate?.protected ?? []);
31
50
  const kept = [];
32
51
  for (const p of config.protected ?? []) {
@@ -45,9 +64,24 @@ function factsOf(root, config) {
45
64
  'docs.specs': config.docs?.specs ?? 'the specs',
46
65
  'docs.plan': config.docs?.plan ?? 'the plan',
47
66
  'docs.principles': config.docs?.principles ?? "the project's principles",
67
+ 'docs.standards': standardsFile(config),
68
+ standards: laneStandards(root, config, lane),
69
+ 'standards.all': standardsOf(root, config)
70
+ .map((s) => `${s.id} ${s.title}`)
71
+ .join('; '),
48
72
  };
49
73
  }
50
74
 
75
+ /** A lane's standards as the lines of a nested list, or '' when none apply. */
76
+ function laneStandards(root, config, lane) {
77
+ if (!lane) return '';
78
+ const layers = (config.layers ?? []).length ? laneLayers(config.lanes?.[lane], config) : null;
79
+ const lines = standardsOf(root, config)
80
+ .filter((s) => appliesTo(s, layers))
81
+ .map((s) => `\n - ${standardLine(s)}`);
82
+ return lines.join('');
83
+ }
84
+
51
85
  const marked = (body) => `${BEGIN(INSTRUCTIONS_VERSION)}\n${body.trim()}\n${END}`;
52
86
 
53
87
  /** The lead's section of CLAUDE.md, between its markers. */
@@ -55,10 +89,10 @@ export function leadSection(root, config) {
55
89
  return marked(fill(readFileSync(join(TEMPLATES, 'claude-md.md'), 'utf8'), factsOf(root, config)));
56
90
  }
57
91
 
58
- /** A lane agent's "Always:" rules, between their markers. */
59
- export function agentRules(root, config) {
92
+ /** A lane agent's "Always:" rules, between their markers: with `lane`, its layers' standards too. */
93
+ export function agentRules(root, config, lane = null) {
60
94
  const template = readFileSync(join(TEMPLATES, 'agent.md'), 'utf8');
61
- return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config)));
95
+ return marked(fill(template.slice(template.indexOf('Always:')), factsOf(root, config, lane)));
62
96
  }
63
97
 
64
98
  /** Whether two renderings say the same, whatever their whitespace and list indentation. */
@@ -87,17 +121,16 @@ function stateOf(text, block, legacy) {
87
121
  */
88
122
  export function instructionsState(root, config) {
89
123
  const lead = leadSection(root, config);
90
- const rules = agentRules(root, config);
91
124
  const files = [
92
125
  { file: 'CLAUDE.md', kind: 'lead', block: lead, legacy: LEAD_LEGACY },
93
- ...[...new Set(Object.values(config.lanes ?? {}).map((l) => l.agent))]
94
- .map((agent) => ({
95
- file: `.claude/agents/${agent}.md`,
126
+ ...Object.entries(config.lanes ?? {})
127
+ .filter(([, l]) => existsSync(join(root, `.claude/agents/${l.agent}.md`)))
128
+ .map(([name, l]) => ({
129
+ file: `.claude/agents/${l.agent}.md`,
96
130
  kind: 'lane',
97
- block: rules,
131
+ block: agentRules(root, config, name),
98
132
  legacy: AGENT_LEGACY,
99
- }))
100
- .filter((f) => existsSync(join(root, f.file))),
133
+ })),
101
134
  ];
102
135
  return files.map(({ file, kind, block, legacy }) => {
103
136
  const path = join(root, file);
@@ -55,3 +55,14 @@ export function layerProblems(rel, source, config) {
55
55
  }
56
56
  return problems;
57
57
  }
58
+
59
+ /**
60
+ * The rule a layer problem broke, read back from its sentence (as layerProblems words it):
61
+ * { kind: 'layers', from, to } or { kind: 'layers', from, package }. Null for any other text.
62
+ */
63
+ export function brokenLayerRule(problem) {
64
+ const pkg = String(problem).match(/ \(([^()]+)\) may not import the package (\S+)\.$/);
65
+ if (pkg) return { kind: 'layers', from: pkg[1], package: pkg[2] };
66
+ const layer = String(problem).match(/ \(([^()]+)\) imports \S+ \(([^()]+)\); /);
67
+ return layer ? { kind: 'layers', from: layer[1], to: layer[2] } : null;
68
+ }
@@ -31,6 +31,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { DEPENDENCY_APPROVAL, isDependencyFile } from './dependencies.mjs';
33
33
  import { globToRegExp, matchesAny } from './glob.mjs';
34
+ import { noteFor } from './standards.mjs';
34
35
 
35
36
  export const APPROVAL_LOG = '.claude/approval-log.jsonl';
36
37
  export const APPROVALS_DIR = '.claude/approvals';
@@ -763,7 +764,7 @@ export function writeProblem(actor, rel, root, config) {
763
764
  decidedBy(guarded.approval, config) === 'lead'
764
765
  ? "the lead's approval"
765
766
  : "a person's approval";
766
- return `${rel} is ${guarded.why} and needs ${who}. ${askHowTo(guarded.approval, actor, root, config)}`;
767
+ return `${rel} is ${guarded.why} and needs ${who}.${noteFor(root, config, { kind: 'protected', name: guarded.approval, glob: guarded.glob })} ${askHowTo(guarded.approval, actor, root, config)}`;
767
768
  }
768
769
  if (matchesAny(rel, config.anyActor)) return null;
769
770
  if (actor.kind === 'readonly') return readonlyProblem(actor, rel);
@@ -14,8 +14,18 @@ import { sendBacks } from './reviews.mjs';
14
14
  import { readMilestones, specCheckRows } from './plan.mjs';
15
15
  import { storyTimes } from './timeline.mjs';
16
16
  import { trail } from './storymap.mjs';
17
+ import {
18
+ APPLIED,
19
+ CATEGORIES,
20
+ LEVELS,
21
+ ORIGINS,
22
+ SOURCES,
23
+ projectStandards,
24
+ sourceOf,
25
+ } from './standards.mjs';
17
26
 
18
- export const SNAPSHOT_VERSION = 1;
27
+ /** 2 adds the project's standards (code-kit spec 22). */
28
+ export const SNAPSHOT_VERSION = 2;
19
29
 
20
30
  /** A story's stage, as a report names it. */
21
31
  export const STAGES = [
@@ -87,8 +97,28 @@ export const SNAPSHOT_SCHEMA = {
87
97
  delegated: { protected: ['name'], dependencies: 'bool', merge: 'bool' },
88
98
  designGate: 'bool',
89
99
  },
100
+ // Spec 22: each standard that applies, by id and title, how it's applied and where it came from;
101
+ // its why in one line at most, and its evidence only as counts.
102
+ standards: [
103
+ {
104
+ id: 'id',
105
+ title: 'title',
106
+ category: `enum:${Object.values(CATEGORIES).join('|')}`,
107
+ level: `enum:${LEVELS.join('|')}`,
108
+ applied: `enum:${APPLIED.join('|')}`,
109
+ layers: ['name'],
110
+ source: `enum:${SOURCES.join('|')}`,
111
+ evidenceCount: 'count?',
112
+ evidenceOf: 'count?',
113
+ origin: `enum:${ORIGINS.join('|')}`,
114
+ why: 'line?',
115
+ },
116
+ ],
90
117
  };
91
118
 
119
+ /** Parts a report from an older code-kit doesn't have yet: valid when missing, checked when there. */
120
+ export const LATER_PARTS = ['standards'];
121
+
92
122
  const ONE_LINE = (max) => (v) => typeof v === 'string' && v.length <= max && !/[\r\n]/.test(v);
93
123
  const KINDS = {
94
124
  id: (v) => typeof v === 'string' && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(v),
@@ -122,7 +152,9 @@ export function snapshotProblems(value, shape = SNAPSHOT_SCHEMA, at = 'snapshot'
122
152
  const extra = Object.keys(value).filter((k) => !(k in shape));
123
153
  return [
124
154
  ...extra.map((k) => `${at}.${k}: not a field a report may hold`),
125
- ...Object.entries(shape).flatMap(([k, s]) => snapshotProblems(value[k], s, `${at}.${k}`)),
155
+ ...Object.entries(shape)
156
+ .filter(([k]) => !(shape === SNAPSHOT_SCHEMA && LATER_PARTS.includes(k) && !(k in value)))
157
+ .flatMap(([k, s]) => snapshotProblems(value[k], s, `${at}.${k}`)),
126
158
  ];
127
159
  }
128
160
 
@@ -143,6 +175,7 @@ export const EVENT_PARTS = [
143
175
  'requirements',
144
176
  'approvals',
145
177
  'rules',
178
+ 'standards',
146
179
  ];
147
180
 
148
181
  const git = (root, ...args) => {
@@ -347,6 +380,45 @@ export function buildSnapshot({
347
380
  },
348
381
  designGate: Boolean(config.designGate),
349
382
  },
383
+ standards: reportedStandards(root, config, line),
350
384
  };
351
385
  return snapshot;
352
386
  }
387
+
388
+ /**
389
+ * Spec 22: the standards that apply, as a report holds them. One that the file can't make sense of (an
390
+ * unknown category, no level) is left out: `code-kit standards` tells the project what's wrong with it.
391
+ */
392
+ function reportedStandards(root, config, line) {
393
+ let all = [];
394
+ try {
395
+ all = projectStandards(root, config).standards;
396
+ } catch {
397
+ return [];
398
+ }
399
+ return all
400
+ .filter(
401
+ (s) =>
402
+ KINDS.id(s.id) &&
403
+ CATEGORIES[s.id.split('-')[0]] &&
404
+ LEVELS.includes(s.level) &&
405
+ APPLIED.includes(s.applied) &&
406
+ KINDS.title(line(s.title) ?? ''),
407
+ )
408
+ .map((s) => {
409
+ const source = s.origin === 'inherited' ? { kind: 'organisation' } : sourceOf(s.source);
410
+ return {
411
+ id: s.id,
412
+ title: line(s.title),
413
+ category: CATEGORIES[s.id.split('-')[0]],
414
+ level: s.level,
415
+ applied: s.applied,
416
+ layers: (s.layers ?? []).filter((l) => KINDS.name(l)),
417
+ source: source.kind,
418
+ evidenceCount: source.count ?? null,
419
+ evidenceOf: source.of ?? null,
420
+ origin: ORIGINS.includes(s.origin) ? s.origin : 'own',
421
+ why: line(s.why),
422
+ };
423
+ });
424
+ }