@b00dea/cockpit 0.1.0 → 0.1.1

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.
Files changed (196) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +3 -0
  3. package/cockpit.schema.json +73 -3
  4. package/dist/autonomy.d.ts +3 -2
  5. package/dist/autonomy.js +30 -13
  6. package/dist/browser/commands.d.ts +57 -2
  7. package/dist/browser/commands.js +114 -10
  8. package/dist/browser/runtime.d.ts +1 -0
  9. package/dist/browser/runtime.js +4 -1
  10. package/dist/browser/surfaces.d.ts +7 -7
  11. package/dist/browser/surfaces.js +5 -0
  12. package/dist/canonical-json.d.ts +2 -0
  13. package/dist/canonical-json.js +5 -0
  14. package/dist/cli-main.d.ts +40 -3
  15. package/dist/cli-main.js +241 -62
  16. package/dist/command-table.d.ts +19 -11
  17. package/dist/command-table.js +190 -38
  18. package/dist/config/load.d.ts +16 -4
  19. package/dist/config/load.js +25 -3
  20. package/dist/config/unconfirmed-toml.d.ts +9 -0
  21. package/dist/config/unconfirmed-toml.js +29 -0
  22. package/dist/diagnostics.js +10 -1
  23. package/dist/docs/check.d.ts +16 -0
  24. package/dist/docs/check.js +197 -0
  25. package/dist/docs/decision-record.d.ts +34 -0
  26. package/dist/docs/decision-record.js +135 -0
  27. package/dist/docs/decisions.d.ts +16 -0
  28. package/dist/docs/decisions.js +103 -0
  29. package/dist/docs/markdown.d.ts +36 -0
  30. package/dist/docs/markdown.js +88 -0
  31. package/dist/docs/references.d.ts +32 -0
  32. package/dist/docs/references.js +148 -0
  33. package/dist/environment/application/confirm.d.ts +35 -0
  34. package/dist/environment/application/confirm.js +122 -0
  35. package/dist/environment/application/destructive.js +6 -2
  36. package/dist/environment/application/doctor.d.ts +12 -7
  37. package/dist/environment/application/doctor.js +20 -8
  38. package/dist/environment/application/guard.d.ts +12 -6
  39. package/dist/environment/application/guard.js +87 -32
  40. package/dist/environment/application/health.d.ts +14 -5
  41. package/dist/environment/application/health.js +8 -2
  42. package/dist/environment/application/lifecycle-context.d.ts +10 -3
  43. package/dist/environment/application/lifecycle-state.js +4 -3
  44. package/dist/environment/application/lifecycle.d.ts +2 -1
  45. package/dist/environment/application/lifecycle.js +11 -3
  46. package/dist/environment/application/services.d.ts +60 -0
  47. package/dist/environment/application/services.js +89 -0
  48. package/dist/environment/application/siblings.d.ts +43 -0
  49. package/dist/environment/application/siblings.js +269 -0
  50. package/dist/environment/claims.d.ts +10 -10
  51. package/dist/environment/claims.js +3 -11
  52. package/dist/environment/compose-topology.d.ts +15 -7
  53. package/dist/environment/compose-topology.js +100 -23
  54. package/dist/environment/contract.d.ts +42 -9
  55. package/dist/environment/contract.js +33 -0
  56. package/dist/environment/executable.d.ts +12 -0
  57. package/dist/environment/executable.js +46 -23
  58. package/dist/environment/generated-application/check.js +2 -0
  59. package/dist/environment/generated-application/prepare.js +2 -0
  60. package/dist/environment/generated-application/shared.d.ts +10 -2
  61. package/dist/environment/generated-application/shared.js +6 -1
  62. package/dist/environment/generated-application/surface-outputs.js +23 -12
  63. package/dist/environment/generated-application/surfaces.js +16 -11
  64. package/dist/environment/generated-application/sync.js +19 -4
  65. package/dist/environment/git-observation.d.ts +25 -6
  66. package/dist/environment/git-observation.js +37 -28
  67. package/dist/environment/health-composition.d.ts +3 -2
  68. package/dist/environment/health-composition.js +19 -2
  69. package/dist/environment/init-application.d.ts +39 -14
  70. package/dist/environment/init-application.js +39 -10
  71. package/dist/environment/lifecycle-composition.d.ts +4 -2
  72. package/dist/environment/lifecycle-composition.js +84 -17
  73. package/dist/environment/lifecycle-stage.d.ts +8 -3
  74. package/dist/environment/lifecycle-stage.js +11 -1
  75. package/dist/environment/logs.d.ts +1 -1
  76. package/dist/environment/map-limit.d.ts +2 -0
  77. package/dist/environment/map-limit.js +13 -0
  78. package/dist/environment/mcp-observation.d.ts +9 -0
  79. package/dist/environment/mcp-observation.js +46 -0
  80. package/dist/environment/observations.d.ts +1 -1
  81. package/dist/environment/observations.js +2 -0
  82. package/dist/environment/operator-application.d.ts +35 -6
  83. package/dist/environment/operator-application.js +42 -0
  84. package/dist/environment/presence.d.ts +4 -0
  85. package/dist/environment/presence.js +26 -0
  86. package/dist/environment/read-composition.d.ts +8 -3
  87. package/dist/environment/read-composition.js +53 -8
  88. package/dist/environment/readiness-composition.d.ts +20 -1
  89. package/dist/environment/readiness-composition.js +23 -2
  90. package/dist/environment/reclaim.d.ts +56 -19
  91. package/dist/environment/reclaim.js +147 -42
  92. package/dist/environment/runtime.d.ts +64 -18
  93. package/dist/environment/runtime.js +73 -3
  94. package/dist/environment/served-checkout-grants.d.ts +53 -0
  95. package/dist/environment/served-checkout-grants.js +182 -0
  96. package/dist/environment/sibling-checkouts.d.ts +119 -0
  97. package/dist/environment/sibling-checkouts.js +254 -0
  98. package/dist/environment/sibling-observation.d.ts +27 -0
  99. package/dist/environment/sibling-observation.js +45 -0
  100. package/dist/environment/sibling-pull.d.ts +23 -0
  101. package/dist/environment/sibling-pull.js +95 -0
  102. package/dist/environment/stack-inputs.d.ts +18 -0
  103. package/dist/environment/stack-inputs.js +210 -0
  104. package/dist/environment/stack-inventory-prune.d.ts +31 -0
  105. package/dist/environment/stack-inventory-prune.js +129 -0
  106. package/dist/environment/stack-inventory-report.d.ts +44 -0
  107. package/dist/environment/stack-inventory-report.js +78 -0
  108. package/dist/environment/stack-inventory.d.ts +94 -0
  109. package/dist/environment/stack-inventory.js +418 -0
  110. package/dist/environment/state.d.ts +48 -9
  111. package/dist/environment/state.js +142 -2
  112. package/dist/environment/tool-registration.js +3 -7
  113. package/dist/environment-exec/execute.d.ts +6 -3
  114. package/dist/environment-exec/execute.js +4 -0
  115. package/dist/fix-catalogue.d.ts +1 -1
  116. package/dist/generated-environment/adopt.js +4 -8
  117. package/dist/guards/answer-confirmation.d.ts +41 -0
  118. package/dist/guards/answer-confirmation.js +109 -0
  119. package/dist/guards/evaluator.d.ts +20 -3
  120. package/dist/guards/evaluator.js +39 -7
  121. package/dist/guards/local-identity.d.ts +6 -2
  122. package/dist/guards/local-identity.js +10 -4
  123. package/dist/guards/policy-change.d.ts +43 -0
  124. package/dist/guards/policy-change.js +159 -0
  125. package/dist/guards/policy-pin.d.ts +1 -1
  126. package/dist/guards/policy-pin.js +14 -12
  127. package/dist/init/command.d.ts +4 -3
  128. package/dist/init/command.js +36 -16
  129. package/dist/init/derive.d.ts +8 -6
  130. package/dist/init/derive.js +140 -36
  131. package/dist/init/draft.d.ts +13 -11
  132. package/dist/init/draft.js +54 -31
  133. package/dist/init/render.d.ts +18 -6
  134. package/dist/init/render.js +31 -9
  135. package/dist/repository-path.d.ts +9 -0
  136. package/dist/repository-path.js +16 -0
  137. package/dist/schema.d.ts +42 -75
  138. package/dist/schema.js +62 -11
  139. package/dist/surfaces/harness-registry.d.ts +80 -0
  140. package/dist/surfaces/harness-registry.js +29 -0
  141. package/dist/surfaces/mcp.d.ts +67 -17
  142. package/dist/surfaces/mcp.js +194 -31
  143. package/dist/surfaces/projection.d.ts +7 -7
  144. package/dist/surfaces/projection.js +41 -6
  145. package/dist/surfaces/render.d.ts +5 -44
  146. package/dist/surfaces/render.js +5 -2
  147. package/dist/team-metadata.js +2 -1
  148. package/dist/testing/test-protection.d.ts +34 -0
  149. package/dist/testing/test-protection.js +137 -0
  150. package/dist/testing/weakening.d.ts +15 -0
  151. package/dist/testing/weakening.js +30 -0
  152. package/dist/work-orders/application-closure.d.ts +7 -0
  153. package/dist/work-orders/application-closure.js +76 -0
  154. package/dist/work-orders/application-context.d.ts +162 -0
  155. package/dist/work-orders/application-context.js +137 -0
  156. package/dist/work-orders/application-decisions.d.ts +27 -0
  157. package/dist/work-orders/application-decisions.js +121 -0
  158. package/dist/work-orders/application-grading.d.ts +11 -0
  159. package/dist/work-orders/application-grading.js +212 -0
  160. package/dist/work-orders/application.d.ts +4 -97
  161. package/dist/work-orders/application.js +94 -254
  162. package/dist/work-orders/composition.d.ts +35 -58
  163. package/dist/work-orders/composition.js +216 -24
  164. package/dist/work-orders/contract.d.ts +22 -6
  165. package/dist/work-orders/contract.js +34 -16
  166. package/dist/work-orders/decisions.d.ts +23 -0
  167. package/dist/work-orders/decisions.js +55 -0
  168. package/dist/work-orders/store.d.ts +54 -5
  169. package/dist/work-orders/store.js +118 -24
  170. package/docs/README.md +6 -3
  171. package/docs/capability-matrix.md +17 -9
  172. package/docs/cli-reference.md +209 -1
  173. package/docs/command-list.md +12 -1
  174. package/docs/concepts.md +6 -4
  175. package/docs/configuration-essentials.md +7 -2
  176. package/docs/configuration-reference.md +18 -3
  177. package/docs/docs-check.md +76 -0
  178. package/docs/environment.md +194 -6
  179. package/docs/faq.md +5 -3
  180. package/docs/getting-started.md +109 -25
  181. package/docs/harness-surfaces.md +69 -2
  182. package/docs/safety-and-autonomy.md +56 -12
  183. package/docs/troubleshooting.md +29 -8
  184. package/docs/tutorial.md +3 -2
  185. package/docs/work-and-browser.md +180 -14
  186. package/libexec/browser-operation.cjs +86 -2
  187. package/package.json +2 -2
  188. package/surfaces/skills/cockpit-browser.skill.md +7 -2
  189. package/surfaces/skills/cockpit-explain.skill.md +1 -1
  190. package/surfaces/skills/cockpit-fix.skill.md +1 -1
  191. package/surfaces/skills/cockpit-setup.skill.md +3 -3
  192. package/surfaces/skills/cockpit-work.skill.md +1 -1
  193. package/surfaces/skills/procedures/explain.md +9 -4
  194. package/surfaces/skills/procedures/fix.md +25 -11
  195. package/surfaces/skills/procedures/setup.md +113 -40
  196. package/surfaces/skills/procedures/work.md +45 -16
@@ -1,10 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
- import { parse } from 'smol-toml';
4
+ import { parse, TomlError } from 'smol-toml';
5
5
  import { RefusalError, ENVIRONMENT_CODES, snapshotJson, } from '../environment/contract.js';
6
6
  import { effectivePolicy, readRecordedPolicy } from '../autonomy.js';
7
- import { CockpitConfig, EVALUATOR_COMMANDS, runtimeDeclarationSha256 } from '../schema.js';
7
+ import { browserDataQualifies, CockpitConfig, EVALUATOR_COMMANDS, runtimeDeclarationSha256 } from '../schema.js';
8
+ /** An explicit request_preapproval wins; otherwise preapproval follows the declared data posture. */
9
+ export const browserPreapprovalRequested = (browser) => browser.request_preapproval ?? browserDataQualifies(browser.data);
8
10
  import { formatShellCommand } from '../fix-catalogue.js';
9
11
  import { readBoundedFile } from '../bounded-read.js';
10
12
  export const STRUCTURED_INPUT_BYTE_LIMIT = 1_048_576;
@@ -42,7 +44,7 @@ function projectBrowserConfig(declaredBrowser) {
42
44
  isolated: declaredBrowser.data.isolated,
43
45
  reachesProduction: declaredBrowser.data.reaches_production,
44
46
  },
45
- requestPreapproval: declaredBrowser.request_preapproval,
47
+ requestPreapproval: browserPreapprovalRequested(declaredBrowser),
46
48
  artifacts: { ...declaredBrowser.artifacts },
47
49
  };
48
50
  }
@@ -65,6 +67,7 @@ function projectTestingConfig(declaredTesting) {
65
67
  })),
66
68
  },
67
69
  nativeHookInstall: declaredTesting.native_hooks.install,
70
+ ...(declaredTesting.test_files === undefined ? {} : { testFiles: [...declaredTesting.test_files] }),
68
71
  ...(declaredTesting.native_hook_policy === undefined ? {} : {
69
72
  nativeHookPolicy: { ...declaredTesting.native_hook_policy },
70
73
  }),
@@ -99,6 +102,10 @@ export function projectWorkEnvironmentConfig(configuration) {
99
102
  generatedHostPaths: Object.freeze([...loaded.config.environment.generated_host_paths]),
100
103
  ...(browser === undefined ? {} : { browser }),
101
104
  ...(testing === undefined ? {} : { testing }),
105
+ ...(loaded.config.docs === undefined ? {} : { docs: Object.freeze({
106
+ ...(loaded.config.docs.decisions === undefined ? {} : { decisions: loaded.config.docs.decisions }),
107
+ ...(loaded.config.docs.decisions_index === undefined ? {} : { decisionsIndex: loaded.config.docs.decisions_index })
108
+ }) }),
102
109
  }));
103
110
  return result;
104
111
  }
@@ -268,6 +275,21 @@ export async function loadAutonomyPolicy(configPath) {
268
275
  const recorded = await readRecordedPolicy(root);
269
276
  return { root, current: effectivePolicy(loaded.result.config), ...recorded ? { recorded } : {} };
270
277
  }
278
+ /** The configuration `bytes` declare, or undefined when they are not UTF-8 TOML the schema accepts. */
279
+ export function parseCockpitConfigBytes(bytes) {
280
+ let parsed;
281
+ try {
282
+ parsed = parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
283
+ }
284
+ catch (error) {
285
+ // Invalid UTF-8 or TOML is the answer; anything else is a fault to surface.
286
+ if (error instanceof TypeError || error instanceof TomlError)
287
+ return undefined;
288
+ throw error;
289
+ }
290
+ const validated = CockpitConfig.safeParse(parsed);
291
+ return validated.success ? validated.data : undefined;
292
+ }
271
293
  export async function loadCockpitConfig(inputPath) {
272
294
  const observed = await readConfigSource(inputPath);
273
295
  if (observed.kind === 'unavailable') {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Owns the text edit `confirm` makes to cockpit.toml: drop one answer from the top-level
3
+ * `unconfirmed = [...]` line, or the whole line once the list empties. Every other byte, comments
4
+ * included, is left alone. The verified write re-parses the result and refuses anything beyond that
5
+ * change; a layout this module cannot locate the list in (a multi-line array, a list inside a table)
6
+ * is refused by name rather than guessed at.
7
+ */
8
+ import { type SiblingEdit } from './sibling-toml.js';
9
+ export declare function removeUnconfirmedAnswer(text: string, answer: string): SiblingEdit;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Owns the text edit `confirm` makes to cockpit.toml: drop one answer from the top-level
3
+ * `unconfirmed = [...]` line, or the whole line once the list empties. Every other byte, comments
4
+ * included, is left alone. The verified write re-parses the result and refuses anything beyond that
5
+ * change; a layout this module cannot locate the list in (a multi-line array, a list inside a table)
6
+ * is refused by name rather than guessed at.
7
+ */
8
+ import { scanHeaders } from './sibling-toml.js';
9
+ const UNCONFIRMED_KEY = /^\s*unconfirmed\s*=/;
10
+ const SINGLE_LINE_ARRAY = /^(\s*unconfirmed\s*=\s*)\[([^\]#]*)\](\s*(?:#.*)?)$/;
11
+ export function removeUnconfirmedAnswer(text, answer) {
12
+ const lines = text.split('\n');
13
+ const firstHeader = scanHeaders(lines)[0]?.line ?? lines.length;
14
+ const keys = lines.slice(0, firstHeader).flatMap((row, index) => UNCONFIRMED_KEY.test(row) ? [index] : []);
15
+ const at = keys[0];
16
+ const current = at === undefined ? null : SINGLE_LINE_ARRAY.exec(lines[at]);
17
+ if (keys.length !== 1 || !current) {
18
+ return { kind: 'unsupported', reason: 'the unconfirmed list is not written on one line as unconfirmed = [...] before the first table' };
19
+ }
20
+ const rows = current[2].split(',').map(row => row.trim()).filter(row => row !== '');
21
+ const kept = rows.filter(row => row !== JSON.stringify(answer) && row !== `'${answer}'`);
22
+ if (kept.length === rows.length)
23
+ return { kind: 'unsupported', reason: `the unconfirmed list does not spell ${answer} as a plain string` };
24
+ if (kept.length === 0)
25
+ lines.splice(at, 1);
26
+ else
27
+ lines[at] = `${current[1]}[${kept.join(', ')}]${current[3]}`;
28
+ return { kind: 'edited', text: lines.join('\n') };
29
+ }
@@ -187,6 +187,13 @@ function lifecycleSummary(command, result) {
187
187
  + resetTargetLines(targets)
188
188
  + 'The next up recreates the removed volumes and, where environment.seed is declared, runs the seed to fill them.\n';
189
189
  }
190
+ if (command === 'ps' && Array.isArray(result.services)) {
191
+ return result.services
192
+ .map(row => `${escapeForTerminal(`${String(row.service)} ${String(row.state)}${typeof row.status === 'string' ? ` ${row.status}` : ''}`)}\n`).join('');
193
+ }
194
+ if ((command === 'restart' || command === 'rebuild') && typeof result.service === 'string' && typeof result.change === 'string') {
195
+ return `${escapeForTerminal(`Service ${result.service} ${result.change} and healthy.`)}\n`;
196
+ }
190
197
  const headline = Object.hasOwn(LIFECYCLE_CHANGES, command) && typeof result.change === 'string'
191
198
  && Object.hasOwn(LIFECYCLE_CHANGES[command], result.change) ? LIFECYCLE_CHANGES[command][result.change] : undefined;
192
199
  if (headline === undefined || typeof result.stackId !== 'string')
@@ -243,6 +250,8 @@ function doctorSummary(result, diagnostics, fixContext) {
243
250
  const declared = named('declared');
244
251
  const absent = named('not-declared');
245
252
  lines.push(`Declared: ${declared.join(', ') || 'nothing'}${absent.length === 0 ? '' : `; not declared: ${absent.join(', ')}`}.`);
253
+ if (declared.includes('docs'))
254
+ lines.push(`Documents: ${bindConfigPath(cockpitCommand('docs check'), fixContext)} checks them; doctor does not.`);
246
255
  }
247
256
  const readiness = result.readiness;
248
257
  if (readiness && Array.isArray(readiness.gaps)) {
@@ -297,7 +306,7 @@ export function renderHumanDiagnostics(diagnostics, review, fixContext, usageHin
297
306
  if (command === 'doctor') {
298
307
  return doctorSummary(outcome.result, outcome.diagnostics ?? [], fixContext);
299
308
  }
300
- if (command === 'selftest' || command === 'upgrade' || command === 'init') {
309
+ if (command === 'selftest' || command === 'upgrade' || command === 'init' || command === 'stacks') {
301
310
  return renderHumanValue(outcome.result);
302
311
  }
303
312
  return command === undefined ? '' : lifecycleSummary(command, outcome.result);
@@ -0,0 +1,16 @@
1
+ import type { ExecuteProbeOutcome } from '../environment-exec/execute.js';
2
+ import { type Diagnostic, type ExecutionStep } from '../environment/contract.js';
3
+ interface DocsCheckInput {
4
+ readonly configPath: string;
5
+ readonly env: Readonly<Record<string, string | undefined>>;
6
+ }
7
+ interface DocsCheckOutcome {
8
+ readonly status: number;
9
+ readonly diagnostics: readonly Diagnostic[];
10
+ readonly result: unknown;
11
+ }
12
+ type Execute = (step: ExecutionStep) => Promise<ExecuteProbeOutcome>;
13
+ export declare function runDocsCheck(input: DocsCheckInput, dependencies: Readonly<{
14
+ execute: Execute;
15
+ }>): Promise<DocsCheckOutcome>;
16
+ export {};
@@ -0,0 +1,197 @@
1
+ // `cockpit docs check`: the living documents and decision records [docs] declares, checked against
2
+ // each repository's files and history. It reads and runs read-only Git; it writes nothing.
3
+ import { lstatIfPresentSync } from '../environment/presence.js';
4
+ import { posix, resolve } from 'node:path';
5
+ import { readBoundedFile } from '../bounded-read.js';
6
+ import { loadCockpitConfig } from '../config/load.js';
7
+ import { ENVIRONMENT_CODES, errorCause } from '../environment/contract.js';
8
+ import { gitRepositoryRefusal } from '../environment/git-observation.js';
9
+ import { redactSensitiveMessage } from '../environment/sensitive.js';
10
+ import { createGitRunner, loadServedSiblings, missingServedCheckout, servedPath } from '../environment/sibling-checkouts.js';
11
+ import { refusalOutcome } from '../refusal.js';
12
+ import { splitRepositoryPath } from '../repository-path.js';
13
+ import { scopeMatcher } from '../work-orders/contract.js';
14
+ import { inspectDecisionRecords } from './decisions.js';
15
+ import { walkMarkdownProse } from './markdown.js';
16
+ import { historicalMakeTargets, makeTargets, parseRemovedPaths, staleReferences } from './references.js';
17
+ const GIT_TIMEOUT_MS = 60_000;
18
+ // The executor's ceiling; a history longer than this is refused rather than read in part.
19
+ const GIT_OUTPUT_LIMIT = 16_777_216;
20
+ const DOCUMENT_LIMIT = 4_194_304;
21
+ const MARKDOWN = /\.(?:md|markdown|mdx)$/i;
22
+ const MAKEFILE = /(?:^|\/)(?:GNUmakefile|[Mm]akefile|[^/]+\.mk)$/;
23
+ const MAKEFILE_PATHSPECS = [':(glob)**/GNUmakefile', ':(glob)**/Makefile', ':(glob)**/makefile', ':(glob)**/*.mk'];
24
+ class EvidenceUnavailable extends Error {
25
+ }
26
+ async function git(runner, root, args) {
27
+ const command = `git -C ${root} ${args.join(' ')}`;
28
+ const ran = await runner.run(root, `git:docs-check:${args[0]}`, args, { timeoutMs: GIT_TIMEOUT_MS, stdoutLimitBytes: GIT_OUTPUT_LIMIT });
29
+ if (ran.kind === 'complete')
30
+ return ran.stdout;
31
+ if (ran.code === ENVIRONMENT_CODES.EXECUTION_STEP_FAILED) {
32
+ // A repository made with `sibling add --create` has no commit yet, so it has no history to read.
33
+ if (args[0] === 'log' && /does not have any commits yet/.test(ran.stderr))
34
+ return '';
35
+ const refused = gitRepositoryRefusal(Buffer.from(ran.stderr));
36
+ throw new EvidenceUnavailable(refused.kind === 'not-a-repository' ? `${root} is not a Git repository` : `${command}: ${redactSensitiveMessage(refused.reason) ?? 'Git printed text that could not be redacted, so it is withheld'}`);
37
+ }
38
+ throw new EvidenceUnavailable(ran.code === ENVIRONMENT_CODES.EXECUTION_STDOUT_LIMIT
39
+ ? `${command} printed more than ${GIT_OUTPUT_LIMIT} bytes` : `${command} did not complete (${ran.detail})`);
40
+ }
41
+ function read(root, path) {
42
+ let bytes;
43
+ try {
44
+ bytes = readBoundedFile(resolve(root, path), DOCUMENT_LIMIT);
45
+ }
46
+ catch (error) {
47
+ throw new EvidenceUnavailable(`${path} could not be read (${errorCause(error)})`);
48
+ }
49
+ if (bytes.kind === 'too-large')
50
+ throw new EvidenceUnavailable(`${path} is larger than ${DOCUMENT_LIMIT} bytes`);
51
+ // Fatal decoding throws only for invalid UTF-8.
52
+ try {
53
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes.bytes);
54
+ }
55
+ catch {
56
+ throw new EvidenceUnavailable(`${path} is not valid UTF-8`);
57
+ }
58
+ }
59
+ function present(root, path) {
60
+ try {
61
+ return lstatIfPresentSync(resolve(root, path)) !== undefined;
62
+ }
63
+ catch (error) {
64
+ throw new EvidenceUnavailable(`${path} could not be inspected (${errorCause(error)})`);
65
+ }
66
+ }
67
+ function removedPaths(output) {
68
+ try {
69
+ return parseRemovedPaths(output);
70
+ }
71
+ catch (error) {
72
+ throw new EvidenceUnavailable(error.message);
73
+ }
74
+ }
75
+ // `<slug>:path` names a sibling's repository; a bare path names the cockpit's own, keyed ''.
76
+ function split(reference) {
77
+ const { slug, path } = splitRepositoryPath(reference);
78
+ return { repository: slug ?? '', path };
79
+ }
80
+ export async function runDocsCheck(input, dependencies) {
81
+ const loaded = await loadCockpitConfig(input.configPath);
82
+ if (loaded.kind !== 'complete')
83
+ return loaded;
84
+ const { config, source } = loaded.result;
85
+ const docs = config.docs;
86
+ if (docs === undefined) {
87
+ return refusalOutcome('DOCS_NOT_DECLARED', `${source.path} has no [docs] table, so there are no living documents or decision records to check`, 'docs', 'add a [docs] table to cockpit.toml: living = ["README.md", "docs/**/*.md"] lists the documents that must stay true, and the optional decisions = "docs/adr" and decisions_index = "docs/adr/README.md" name the decision records and their index; then rerun npx --no cockpit docs check --config <your cockpit.toml>');
88
+ }
89
+ const runner = await createGitRunner(input.env, dependencies.execute);
90
+ if ('unavailable' in runner) {
91
+ return refusalOutcome(ENVIRONMENT_CODES.EVIDENCE_UNAVAILABLE, `docs check reads repository history with Git, which is unavailable: ${runner.unavailable}`, 'git', 'install Git or put it on PATH, then rerun npx --no cockpit docs check --config <your cockpit.toml>');
92
+ }
93
+ const cockpitRoot = posix.dirname(source.path);
94
+ // A sibling `cockpit use` serves from another checkout is checked there, where the stack runs it.
95
+ const served = await loadServedSiblings(cockpitRoot, config);
96
+ if (served.kind !== 'complete')
97
+ return { status: 2, diagnostics: [served.diagnostic], result: null };
98
+ const gone = await missingServedCheckout(served.siblings);
99
+ if (gone)
100
+ return { status: 2, diagnostics: [gone], result: null };
101
+ const rootOf = (repository) => repository === '' ? cockpitRoot : servedPath(served.siblings, repository);
102
+ const decisions = docs.decisions === undefined ? undefined : split(docs.decisions);
103
+ const living = docs.living.map(split);
104
+ const repositories = [...new Set([...living.map(entry => entry.repository), ...decisions ? [decisions.repository] : []])];
105
+ const checked = await Promise.all(repositories.map(repository => checkRepository({
106
+ repository, root: rootOf(repository), runner,
107
+ globs: living.filter(entry => entry.repository === repository).map(entry => entry.path),
108
+ ...decisions?.repository === repository ? { decisions: { directory: decisions.path,
109
+ ...docs.decisions_index === undefined ? {} : { index: split(docs.decisions_index).path } } } : {},
110
+ })));
111
+ // Joined in declaration order, so the report does not depend on which repository Git answered first.
112
+ const findings = checked.flatMap(row => row.findings);
113
+ return { status: findings.length === 0 ? 0 : 2, diagnostics: findings,
114
+ result: { repositories: checked.map(row => row.summary), findings: findings.length } };
115
+ }
116
+ async function checkRepository(input) {
117
+ const { repository, root, globs } = input;
118
+ const prefix = repository === '' ? '' : `${repository}:`;
119
+ const findings = [];
120
+ const unavailable = (error) => {
121
+ if (!(error instanceof EvidenceUnavailable))
122
+ throw error;
123
+ findings.push({ severity: 'error', code: ENVIRONMENT_CODES.EVIDENCE_UNAVAILABLE,
124
+ message: `the documents in ${repository === '' ? 'the cockpit repository' : `sibling ${repository}`} could not be checked: ${error.message}`,
125
+ target: repository === '' ? 'docs' : `sibling:${repository}`,
126
+ fix: 'fix what the message names (for a missing sibling checkout, run npx --no cockpit setup --config <your cockpit.toml>), then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
127
+ };
128
+ let livingDocuments = 0;
129
+ let decisionFiles = 0;
130
+ try {
131
+ // Untracked files that are not ignored count, so a document added in this change is checked before its commit.
132
+ const [listed, history] = await Promise.all([
133
+ git(input.runner, root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']),
134
+ globs.length > 0 ? Promise.all([
135
+ git(input.runner, root, ['log', '--relative', '--format=', '--name-status', '-z', '-M', '--diff-filter=DR']),
136
+ git(input.runner, root, ['log', '--relative', '-p', '--format=', '--no-color', '--no-ext-diff', '--no-textconv', '--', ...MAKEFILE_PATHSPECS]),
137
+ ]) : undefined,
138
+ ]);
139
+ const all = listed.split('\0').filter(Boolean);
140
+ const files = new Set(all);
141
+ const markdown = all.filter(file => MARKDOWN.test(file));
142
+ if (history) {
143
+ const [names, patch] = history;
144
+ for (const glob of globs) {
145
+ if (!markdown.some(scopeMatcher([glob]))) {
146
+ findings.push({ severity: 'error', code: 'DOCS_DECLARATION_UNMATCHED', target: `${prefix}${glob}`,
147
+ message: `[docs] living entry ${prefix}${glob} matches no Markdown file, so it checks nothing`,
148
+ fix: 'correct the glob under [docs] living in cockpit.toml to match the documents, or remove it; then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
149
+ }
150
+ }
151
+ const documents = [];
152
+ for (const path of markdown.filter(scopeMatcher(globs)).sort()) {
153
+ let body;
154
+ try {
155
+ body = read(root, path);
156
+ }
157
+ catch (error) {
158
+ unavailable(error);
159
+ continue;
160
+ }
161
+ // The walker throws only for an HTML comment that never closes.
162
+ try {
163
+ documents.push({ path, walked: walkMarkdownProse(body) });
164
+ }
165
+ catch (error) {
166
+ unavailable(new EvidenceUnavailable(`${path}: ${error.message}`));
167
+ }
168
+ }
169
+ livingDocuments = documents.length;
170
+ const current = makeTargets(all.filter(file => MAKEFILE.test(file)).flatMap(file => read(root, file).split('\n')));
171
+ findings.push(...staleReferences({ repository: prefix, documents, files, targets: current, present: path => present(root, path),
172
+ history: { removed: removedPaths(names), targets: historicalMakeTargets(patch) } }));
173
+ }
174
+ if (input.decisions) {
175
+ const directory = posix.normalize(input.decisions.directory).replace(/\/$/, '');
176
+ const index = input.decisions.index === undefined ? undefined : posix.normalize(input.decisions.index);
177
+ const records = markdown.filter(file => posix.dirname(file) === directory);
178
+ const missing = records.length === 0 ? { target: directory, message: `[docs] decisions ${prefix}${directory} holds no Markdown file` }
179
+ : index !== undefined && !files.has(index) ? { target: index, message: `[docs] decisions_index ${prefix}${index} does not exist` } : undefined;
180
+ if (missing) {
181
+ findings.push({ severity: 'error', code: 'DOCS_DECLARATION_UNMATCHED',
182
+ target: `${prefix}${missing.target}`, message: `${missing.message}, so it checks nothing`,
183
+ fix: 'correct decisions or decisions_index under [docs] in cockpit.toml to the directory of decision records and the file that lists them, or remove the key; then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
184
+ }
185
+ else {
186
+ decisionFiles = records.length;
187
+ findings.push(...inspectDecisionRecords({ repository: prefix, directory,
188
+ files: new Map(records.map(name => [posix.basename(name), read(root, name)])),
189
+ ...index === undefined ? {} : { index: { path: index, body: read(root, index) } } }));
190
+ }
191
+ }
192
+ }
193
+ catch (error) {
194
+ unavailable(error);
195
+ }
196
+ return { summary: { repository: repository === '' ? '.' : repository, root, livingDocuments, decisionFiles }, findings };
197
+ }
@@ -0,0 +1,34 @@
1
+ /** Whether a record file is named by date, and so carries no number. */
2
+ export declare const isDatedRecordName: (name: string) => boolean;
3
+ /** The digits a record file is numbered by, or undefined for a dated or unnumbered name. */
4
+ export declare function recordNumber(name: string): string | undefined;
5
+ /** The first word of a status, lowercased: `accepted` for "**Accepted** (2026-01-02)". */
6
+ export declare function leadingWord(value: string): string;
7
+ type SupersedesReference = Readonly<{
8
+ line: number;
9
+ text: string;
10
+ full: boolean;
11
+ number?: number;
12
+ file?: string;
13
+ }>;
14
+ /** Where a status is stated: a front-matter field, a `Status:` line, or the line under a `## Status` heading. */
15
+ type RecordStatus = Readonly<{
16
+ line: number;
17
+ value: string;
18
+ form: 'front-matter' | 'line' | 'heading';
19
+ }>;
20
+ export interface ParsedRecord {
21
+ readonly status?: RecordStatus;
22
+ readonly supersedes: readonly SupersedesReference[];
23
+ }
24
+ /** A record's status and supersedes references, or why it cannot be read. `name` is its path inside the records' directory. */
25
+ export declare function parseDecisionRecord(name: string, body: string): ParsedRecord | Readonly<{
26
+ unreadable: string;
27
+ }>;
28
+ /**
29
+ * The record's text with its status set to `status`, where `parseDecisionRecord` finds it and in the
30
+ * same form; undefined when the record states no status, or states it on a line this cannot rewrite
31
+ * in place (one an HTML comment shares).
32
+ */
33
+ export declare function withStatus(text: string, status: string): string | undefined;
34
+ export {};
@@ -0,0 +1,135 @@
1
+ // The grammar of one decision record: how its file name numbers it, where its status is stated and
2
+ // which records it supersedes. `docs check` reads records with it and `work decide` writes them with
3
+ // it, so a record one accepts is a record the other can number and mark superseded.
4
+ import { posix } from 'node:path';
5
+ import { parseDocument } from 'yaml';
6
+ import { resolveLinkTarget, walkMarkdownProse } from './markdown.js';
7
+ // Dated names (2026-09-<slug>.md) order by date and carry no number to collide.
8
+ const DATED = /^(?:19|20)\d{2}-\d{2}(?:-\d{2})?(?=[-_.])/;
9
+ const NUMBERED = /^(\d+)(?=[-_. ])/;
10
+ const STATUS_LINE = /^(\s*(?:[-*]\s+)?(?:\*\*|__)?status(?:\*\*|__)?\s*:\s*(?:\*\*|__)?\s*)(.*)$/i;
11
+ const STATUS_HEADING = /^#{1,6}\s+status\s*$/i;
12
+ const FRONT_MATTER_STATUS = /^(status\s*:\s*)/i;
13
+ const KEYWORD = /\b(supersedes|superseded\s+by)\b/gi;
14
+ // A link (rewritten to [[target]]), a file name, `ADR-12`, `#12`, or a zero-padded `of 0012`; a bare
15
+ // number counts only right after the keyword, so a year later in the sentence is not read as a record.
16
+ const REFERENCE = /\[\[([^\]]+)\]\]|(?:\b(?:adr|decision|record)s?[-\s]?#?|#|\b(?:of|in)\s+(?=0))(\d{1,5})\b|([\w./-]+\.md)\b/gi;
17
+ /** Whether a record file is named by date, and so carries no number. */
18
+ export const isDatedRecordName = (name) => DATED.test(name);
19
+ /** The digits a record file is numbered by, or undefined for a dated or unnumbered name. */
20
+ export function recordNumber(name) {
21
+ return DATED.test(name) ? undefined : NUMBERED.exec(name)?.[1];
22
+ }
23
+ /** The first word of a status, lowercased: `accepted` for "**Accepted** (2026-01-02)". */
24
+ export function leadingWord(value) {
25
+ return value.replace(/[*_`[\]]/g, '').trim().split(/[\s,.;:()]/, 1)[0].toLowerCase();
26
+ }
27
+ // A link becomes [[target]] so its target survives as one token; code spans keep their text.
28
+ function referenceText(prose) {
29
+ return prose.replace(/\[([^\]]*)\]\(([^)\s]*)[^)]*\)/g, (_match, _label, target) => ` [[${target}]] `).replace(/`/g, ' ');
30
+ }
31
+ function referencesIn(line, text, record) {
32
+ const found = [];
33
+ const keywords = [...text.matchAll(KEYWORD)];
34
+ keywords.forEach((keyword, position) => {
35
+ const start = keyword.index + keyword[0].length;
36
+ // The clause the keyword governs: up to the next keyword, a semicolon or a sentence end.
37
+ const end = Math.min(keywords[position + 1]?.index ?? text.length, ...[/;/, /\.(?:\s|$)/].map(pattern => { const at = text.slice(start).search(pattern); return at === -1 ? text.length : start + at; }));
38
+ const clause = text.slice(start, end);
39
+ const outgoing = keyword[1].toLowerCase() === 'supersedes';
40
+ // "Supersedes 0012" names the whole record; "supersedes the cache clause of 0012" only a part.
41
+ // A number that runs on into a name (`2026-09-cache.md`, `0012-x.md`) is a file name, read below.
42
+ const bare = /^\s*:?\s*(\d{1,5})\b(?![-.]\w)/.exec(clause);
43
+ const leading = /^\s*:?\s*(?:\[\[|(?:adr|decision|record)s?\b|#|[\w./-]+\.md\b)/i.test(clause) || bare !== null;
44
+ // "Supersedes 0012 (ADR-0012)" names one record twice; it is one reference.
45
+ const add = (reference, text) => {
46
+ if (!found.some(row => row.number === reference.number && row.file === reference.file))
47
+ found.push({ line, text, full: outgoing && leading, ...reference });
48
+ };
49
+ if (bare)
50
+ add({ number: Number(bare[1]) }, bare[1]);
51
+ for (const match of clause.matchAll(REFERENCE)) {
52
+ const target = match[1] ?? match[3];
53
+ if (target !== undefined) {
54
+ const file = resolveLinkTarget(posix.dirname(record), target);
55
+ if (file?.endsWith('.md'))
56
+ add({ file }, target);
57
+ }
58
+ else
59
+ add({ number: Number(match[2]) }, match[0].trim());
60
+ }
61
+ });
62
+ return found;
63
+ }
64
+ /** A record's status and supersedes references, or why it cannot be read. `name` is its path inside the records' directory. */
65
+ export function parseDecisionRecord(name, body) {
66
+ let walked;
67
+ try {
68
+ walked = walkMarkdownProse(body);
69
+ }
70
+ catch (error) {
71
+ return { unreadable: error.message };
72
+ }
73
+ const rows = body.split(/\r?\n/);
74
+ let status;
75
+ const supersedes = [];
76
+ if (rows[0] === '---') {
77
+ const end = rows.indexOf('---', 1);
78
+ const front = rows.slice(1, end === -1 ? 1 : end);
79
+ const parsed = parseDocument(front.join('\n'));
80
+ if (parsed.errors.length > 0)
81
+ return { unreadable: `its front matter is not valid YAML (${parsed.errors[0].message.split('\n', 1)[0]})` };
82
+ const data = parsed.toJS();
83
+ if (data && typeof data === 'object') {
84
+ if (typeof data.status === 'string') {
85
+ // Counted from the opening `---`; 0 where the field is not written on a line of its own.
86
+ const at = front.findIndex(row => FRONT_MATTER_STATUS.test(row));
87
+ status = { line: at === -1 ? 0 : at + 2, value: data.status, form: 'front-matter' };
88
+ }
89
+ for (const [field, text] of [['supersedes', 'Supersedes'], ['superseded_by', 'Superseded by']]) {
90
+ for (const value of [data[field]].flat()) {
91
+ if (typeof value === 'string' || typeof value === 'number') {
92
+ supersedes.push(...referencesIn(rows.findIndex(row => row.startsWith(`${field}:`)) + 1, `${text} ${value}`, name));
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ walked.lines.forEach((row, position) => {
99
+ const prose = row.prose;
100
+ if (status === undefined) {
101
+ const line = STATUS_LINE.exec(prose);
102
+ if (line && line[2].trim() !== '')
103
+ status = { line: row.line, value: line[2], form: 'line' };
104
+ else if (STATUS_HEADING.test(prose)) {
105
+ const next = walked.lines.slice(position + 1).find(candidate => candidate.prose.trim() !== '');
106
+ if (next && !/^#/.test(next.prose))
107
+ status = { line: next.line, value: next.prose, form: 'heading' };
108
+ }
109
+ }
110
+ supersedes.push(...referencesIn(row.line, referenceText(prose), name));
111
+ });
112
+ return { ...status ? { status } : {}, supersedes };
113
+ }
114
+ /**
115
+ * The record's text with its status set to `status`, where `parseDecisionRecord` finds it and in the
116
+ * same form; undefined when the record states no status, or states it on a line this cannot rewrite
117
+ * in place (one an HTML comment shares).
118
+ */
119
+ export function withStatus(text, status) {
120
+ const parsed = parseDecisionRecord('', text);
121
+ if ('unreadable' in parsed || parsed.status === undefined)
122
+ return undefined;
123
+ const rows = text.split('\n');
124
+ const index = parsed.status.line - 1;
125
+ const row = rows[index];
126
+ if (row === undefined)
127
+ return undefined;
128
+ const ending = row.endsWith('\r') ? '\r' : '';
129
+ const raw = ending ? row.slice(0, -1) : row;
130
+ const prefix = parsed.status.form === 'heading' ? '' : (parsed.status.form === 'line' ? STATUS_LINE : FRONT_MATTER_STATUS).exec(raw)?.[1];
131
+ if (prefix === undefined)
132
+ return undefined;
133
+ rows[index] = `${prefix}${status}${ending}`;
134
+ return rows.join('\n');
135
+ }
@@ -0,0 +1,16 @@
1
+ import { type Diagnostic } from '../environment/contract.js';
2
+ interface DecisionRecordSet {
3
+ /** Prefixed onto every reported path: '' in the cockpit's own repository, `<slug>:` in a sibling. */
4
+ readonly repository: string;
5
+ /** The records' directory, relative to the repository root. */
6
+ readonly directory: string;
7
+ /** Each Markdown file directly inside the directory, by file name. */
8
+ readonly files: ReadonlyMap<string, string>;
9
+ readonly index?: Readonly<{
10
+ path: string;
11
+ body: string;
12
+ }>;
13
+ }
14
+ /** Every integrity finding in one decision-record set. Index and template files are not records. */
15
+ export declare function inspectDecisionRecords(set: DecisionRecordSet): Diagnostic[];
16
+ export {};
@@ -0,0 +1,103 @@
1
+ // The integrity of a decision-record set: numbering, the index, statuses and supersedes links.
2
+ // Decision records drift in ways nothing else notices: concurrent branches each take the next
3
+ // free number, a record lands without an index line, and a record superseded in full keeps
4
+ // reading "Accepted" because only the newer one was edited.
5
+ import { posix } from 'node:path';
6
+ import { ENVIRONMENT_CODES } from '../environment/contract.js';
7
+ import { leadingWord, parseDecisionRecord, recordNumber } from './decision-record.js';
8
+ import { resolveLinkTarget, walkMarkdownProse } from './markdown.js';
9
+ const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'deprecated', 'rejected'];
10
+ const numberOf = (name) => { const digits = recordNumber(name); return digits === undefined ? undefined : Number(digits); };
11
+ /** Every integrity finding in one decision-record set. Index and template files are not records. */
12
+ export function inspectDecisionRecords(set) {
13
+ const findings = [];
14
+ const at = (name, line) => `${set.repository}${posix.join(set.directory, name)}${line ? `:${line}` : ''}`;
15
+ const indexName = set.index && posix.dirname(set.index.path) === set.directory ? posix.basename(set.index.path) : undefined;
16
+ // A README or index page lists the records, and a template is the shape of one; neither decides anything.
17
+ const names = [...set.files.keys()].filter(name => name !== indexName && !/^(?:readme|index)\.md$/i.test(name) && !/template/i.test(name)).sort();
18
+ const records = new Map();
19
+ for (const name of names) {
20
+ const parsed = parseDecisionRecord(name, set.files.get(name));
21
+ if ('unreadable' in parsed) {
22
+ findings.push({ severity: 'error', code: ENVIRONMENT_CODES.EVIDENCE_UNAVAILABLE,
23
+ message: `decision record ${at(name)} could not be read: ${parsed.unreadable}`, target: at(name),
24
+ fix: 'correct the record so it parses as Markdown with optional YAML front matter, then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
25
+ continue;
26
+ }
27
+ records.set(name, parsed);
28
+ }
29
+ const byNumber = new Map();
30
+ for (const name of names) {
31
+ const number = numberOf(name);
32
+ if (number !== undefined)
33
+ byNumber.set(number, [...byNumber.get(number) ?? [], name]);
34
+ }
35
+ for (const [number, shared] of byNumber) {
36
+ if (shared.length < 2)
37
+ continue;
38
+ // Not "_DUPLICATE": the CLI reads that suffix as a repeated option and prints a usage hint.
39
+ findings.push({ severity: 'error', code: 'DECISION_NUMBER_REUSED', target: at(shared[1]),
40
+ message: `decision records ${shared.map(name => at(name)).join(' and ')} share number ${number}`,
41
+ fix: 'renumber the later record to the next free number, then update the index and every supersedes line that cites it' });
42
+ }
43
+ for (const [name, record] of records) {
44
+ const status = record.status === undefined ? undefined : leadingWord(record.status.value);
45
+ if (!DECISION_STATUSES.some(known => known === status)) {
46
+ findings.push({ severity: 'error', code: 'DECISION_STATUS_MISSING', target: at(name, record.status?.line),
47
+ message: record.status === undefined ? `decision record ${at(name)} states no status`
48
+ : `decision record ${at(name)} has status "${record.status.value.trim()}", which starts with none of ${DECISION_STATUSES.join(', ')}`,
49
+ fix: `give the record a status line such as "Status: Accepted", or status: accepted in its front matter, starting with one of ${DECISION_STATUSES.join(', ')}` });
50
+ }
51
+ for (const reference of record.supersedes) {
52
+ const target = reference.file !== undefined
53
+ ? posix.dirname(reference.file) === '.' && records.has(reference.file) ? reference.file : undefined
54
+ : names.find(candidate => numberOf(candidate) === reference.number);
55
+ if (target === undefined) {
56
+ findings.push({ severity: 'error', code: 'DECISION_LINK_UNRESOLVED', target: at(name, reference.line),
57
+ message: `decision record ${at(name, reference.line)} cites ${reference.text}, which names no record in ${set.repository}${set.directory}`,
58
+ fix: 'correct the number or link to name an existing record, or restore the record it names' });
59
+ continue;
60
+ }
61
+ const superseded = records.get(target)?.status;
62
+ // Only an accepted record replaces another; a proposed or rejected one leaves it standing.
63
+ if (reference.full && ['accepted', 'superseded', 'deprecated'].includes(status ?? '') && superseded && leadingWord(superseded.value) === 'accepted') {
64
+ findings.push({ severity: 'error', code: 'DECISION_SUPERSEDED_STALE', target: at(target, superseded.line),
65
+ message: `decision record ${at(target)} still has status "${superseded.value.trim()}", but ${at(name, reference.line)} supersedes it`,
66
+ fix: `change the superseded record's status to "Superseded by" the newer record; if only part of it is replaced, say which part in the newer record's supersedes line, such as "supersedes the retry clause of ${target}"` });
67
+ }
68
+ }
69
+ }
70
+ if (set.index) {
71
+ let links;
72
+ try {
73
+ links = walkMarkdownProse(set.index.body).links;
74
+ }
75
+ catch (error) {
76
+ findings.push({ severity: 'error', code: ENVIRONMENT_CODES.EVIDENCE_UNAVAILABLE,
77
+ message: `decision index ${set.repository}${set.index.path} could not be read: ${error.message}`, target: `${set.repository}${set.index.path}`,
78
+ fix: 'close the HTML comment the message names, then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
79
+ return findings;
80
+ }
81
+ const linked = new Set();
82
+ for (const link of links) {
83
+ const resolved = resolveLinkTarget(posix.dirname(set.index.path), link.target);
84
+ if (resolved === undefined || !resolved.endsWith('.md') || posix.dirname(resolved) !== set.directory)
85
+ continue;
86
+ const name = posix.basename(resolved);
87
+ linked.add(name);
88
+ if (!set.files.has(name)) {
89
+ findings.push({ severity: 'error', code: 'DECISION_INDEX_ENTRY_MISSING', target: `${set.repository}${set.index.path}:${link.line}`,
90
+ message: `decision index ${set.repository}${set.index.path}:${link.line} links ${link.target}, which does not exist`,
91
+ fix: 'correct the link to the record\'s current file name, or remove the entry if the record was deleted on purpose' });
92
+ }
93
+ }
94
+ for (const name of names) {
95
+ if (linked.has(name))
96
+ continue;
97
+ findings.push({ severity: 'error', code: 'DECISION_UNINDEXED', target: at(name),
98
+ message: `decision record ${at(name)} is not linked from ${set.repository}${set.index.path}`,
99
+ fix: 'add a line linking the record to the decision index' });
100
+ }
101
+ }
102
+ return findings;
103
+ }