@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.
- package/CHANGELOG.md +99 -0
- package/README.md +3 -0
- package/cockpit.schema.json +73 -3
- package/dist/autonomy.d.ts +3 -2
- package/dist/autonomy.js +30 -13
- package/dist/browser/commands.d.ts +57 -2
- package/dist/browser/commands.js +114 -10
- package/dist/browser/runtime.d.ts +1 -0
- package/dist/browser/runtime.js +4 -1
- package/dist/browser/surfaces.d.ts +7 -7
- package/dist/browser/surfaces.js +5 -0
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +5 -0
- package/dist/cli-main.d.ts +40 -3
- package/dist/cli-main.js +241 -62
- package/dist/command-table.d.ts +19 -11
- package/dist/command-table.js +190 -38
- package/dist/config/load.d.ts +16 -4
- package/dist/config/load.js +25 -3
- package/dist/config/unconfirmed-toml.d.ts +9 -0
- package/dist/config/unconfirmed-toml.js +29 -0
- package/dist/diagnostics.js +10 -1
- package/dist/docs/check.d.ts +16 -0
- package/dist/docs/check.js +197 -0
- package/dist/docs/decision-record.d.ts +34 -0
- package/dist/docs/decision-record.js +135 -0
- package/dist/docs/decisions.d.ts +16 -0
- package/dist/docs/decisions.js +103 -0
- package/dist/docs/markdown.d.ts +36 -0
- package/dist/docs/markdown.js +88 -0
- package/dist/docs/references.d.ts +32 -0
- package/dist/docs/references.js +148 -0
- package/dist/environment/application/confirm.d.ts +35 -0
- package/dist/environment/application/confirm.js +122 -0
- package/dist/environment/application/destructive.js +6 -2
- package/dist/environment/application/doctor.d.ts +12 -7
- package/dist/environment/application/doctor.js +20 -8
- package/dist/environment/application/guard.d.ts +12 -6
- package/dist/environment/application/guard.js +87 -32
- package/dist/environment/application/health.d.ts +14 -5
- package/dist/environment/application/health.js +8 -2
- package/dist/environment/application/lifecycle-context.d.ts +10 -3
- package/dist/environment/application/lifecycle-state.js +4 -3
- package/dist/environment/application/lifecycle.d.ts +2 -1
- package/dist/environment/application/lifecycle.js +11 -3
- package/dist/environment/application/services.d.ts +60 -0
- package/dist/environment/application/services.js +89 -0
- package/dist/environment/application/siblings.d.ts +43 -0
- package/dist/environment/application/siblings.js +269 -0
- package/dist/environment/claims.d.ts +10 -10
- package/dist/environment/claims.js +3 -11
- package/dist/environment/compose-topology.d.ts +15 -7
- package/dist/environment/compose-topology.js +100 -23
- package/dist/environment/contract.d.ts +42 -9
- package/dist/environment/contract.js +33 -0
- package/dist/environment/executable.d.ts +12 -0
- package/dist/environment/executable.js +46 -23
- package/dist/environment/generated-application/check.js +2 -0
- package/dist/environment/generated-application/prepare.js +2 -0
- package/dist/environment/generated-application/shared.d.ts +10 -2
- package/dist/environment/generated-application/shared.js +6 -1
- package/dist/environment/generated-application/surface-outputs.js +23 -12
- package/dist/environment/generated-application/surfaces.js +16 -11
- package/dist/environment/generated-application/sync.js +19 -4
- package/dist/environment/git-observation.d.ts +25 -6
- package/dist/environment/git-observation.js +37 -28
- package/dist/environment/health-composition.d.ts +3 -2
- package/dist/environment/health-composition.js +19 -2
- package/dist/environment/init-application.d.ts +39 -14
- package/dist/environment/init-application.js +39 -10
- package/dist/environment/lifecycle-composition.d.ts +4 -2
- package/dist/environment/lifecycle-composition.js +84 -17
- package/dist/environment/lifecycle-stage.d.ts +8 -3
- package/dist/environment/lifecycle-stage.js +11 -1
- package/dist/environment/logs.d.ts +1 -1
- package/dist/environment/map-limit.d.ts +2 -0
- package/dist/environment/map-limit.js +13 -0
- package/dist/environment/mcp-observation.d.ts +9 -0
- package/dist/environment/mcp-observation.js +46 -0
- package/dist/environment/observations.d.ts +1 -1
- package/dist/environment/observations.js +2 -0
- package/dist/environment/operator-application.d.ts +35 -6
- package/dist/environment/operator-application.js +42 -0
- package/dist/environment/presence.d.ts +4 -0
- package/dist/environment/presence.js +26 -0
- package/dist/environment/read-composition.d.ts +8 -3
- package/dist/environment/read-composition.js +53 -8
- package/dist/environment/readiness-composition.d.ts +20 -1
- package/dist/environment/readiness-composition.js +23 -2
- package/dist/environment/reclaim.d.ts +56 -19
- package/dist/environment/reclaim.js +147 -42
- package/dist/environment/runtime.d.ts +64 -18
- package/dist/environment/runtime.js +73 -3
- package/dist/environment/served-checkout-grants.d.ts +53 -0
- package/dist/environment/served-checkout-grants.js +182 -0
- package/dist/environment/sibling-checkouts.d.ts +119 -0
- package/dist/environment/sibling-checkouts.js +254 -0
- package/dist/environment/sibling-observation.d.ts +27 -0
- package/dist/environment/sibling-observation.js +45 -0
- package/dist/environment/sibling-pull.d.ts +23 -0
- package/dist/environment/sibling-pull.js +95 -0
- package/dist/environment/stack-inputs.d.ts +18 -0
- package/dist/environment/stack-inputs.js +210 -0
- package/dist/environment/stack-inventory-prune.d.ts +31 -0
- package/dist/environment/stack-inventory-prune.js +129 -0
- package/dist/environment/stack-inventory-report.d.ts +44 -0
- package/dist/environment/stack-inventory-report.js +78 -0
- package/dist/environment/stack-inventory.d.ts +94 -0
- package/dist/environment/stack-inventory.js +418 -0
- package/dist/environment/state.d.ts +48 -9
- package/dist/environment/state.js +142 -2
- package/dist/environment/tool-registration.js +3 -7
- package/dist/environment-exec/execute.d.ts +6 -3
- package/dist/environment-exec/execute.js +4 -0
- package/dist/fix-catalogue.d.ts +1 -1
- package/dist/generated-environment/adopt.js +4 -8
- package/dist/guards/answer-confirmation.d.ts +41 -0
- package/dist/guards/answer-confirmation.js +109 -0
- package/dist/guards/evaluator.d.ts +20 -3
- package/dist/guards/evaluator.js +39 -7
- package/dist/guards/local-identity.d.ts +6 -2
- package/dist/guards/local-identity.js +10 -4
- package/dist/guards/policy-change.d.ts +43 -0
- package/dist/guards/policy-change.js +159 -0
- package/dist/guards/policy-pin.d.ts +1 -1
- package/dist/guards/policy-pin.js +14 -12
- package/dist/init/command.d.ts +4 -3
- package/dist/init/command.js +36 -16
- package/dist/init/derive.d.ts +8 -6
- package/dist/init/derive.js +140 -36
- package/dist/init/draft.d.ts +13 -11
- package/dist/init/draft.js +54 -31
- package/dist/init/render.d.ts +18 -6
- package/dist/init/render.js +31 -9
- package/dist/repository-path.d.ts +9 -0
- package/dist/repository-path.js +16 -0
- package/dist/schema.d.ts +42 -75
- package/dist/schema.js +62 -11
- package/dist/surfaces/harness-registry.d.ts +80 -0
- package/dist/surfaces/harness-registry.js +29 -0
- package/dist/surfaces/mcp.d.ts +67 -17
- package/dist/surfaces/mcp.js +194 -31
- package/dist/surfaces/projection.d.ts +7 -7
- package/dist/surfaces/projection.js +41 -6
- package/dist/surfaces/render.d.ts +5 -44
- package/dist/surfaces/render.js +5 -2
- package/dist/team-metadata.js +2 -1
- package/dist/testing/test-protection.d.ts +34 -0
- package/dist/testing/test-protection.js +137 -0
- package/dist/testing/weakening.d.ts +15 -0
- package/dist/testing/weakening.js +30 -0
- package/dist/work-orders/application-closure.d.ts +7 -0
- package/dist/work-orders/application-closure.js +76 -0
- package/dist/work-orders/application-context.d.ts +162 -0
- package/dist/work-orders/application-context.js +137 -0
- package/dist/work-orders/application-decisions.d.ts +27 -0
- package/dist/work-orders/application-decisions.js +121 -0
- package/dist/work-orders/application-grading.d.ts +11 -0
- package/dist/work-orders/application-grading.js +212 -0
- package/dist/work-orders/application.d.ts +4 -97
- package/dist/work-orders/application.js +94 -254
- package/dist/work-orders/composition.d.ts +35 -58
- package/dist/work-orders/composition.js +216 -24
- package/dist/work-orders/contract.d.ts +22 -6
- package/dist/work-orders/contract.js +34 -16
- package/dist/work-orders/decisions.d.ts +23 -0
- package/dist/work-orders/decisions.js +55 -0
- package/dist/work-orders/store.d.ts +54 -5
- package/dist/work-orders/store.js +118 -24
- package/docs/README.md +6 -3
- package/docs/capability-matrix.md +17 -9
- package/docs/cli-reference.md +209 -1
- package/docs/command-list.md +12 -1
- package/docs/concepts.md +6 -4
- package/docs/configuration-essentials.md +7 -2
- package/docs/configuration-reference.md +18 -3
- package/docs/docs-check.md +76 -0
- package/docs/environment.md +194 -6
- package/docs/faq.md +5 -3
- package/docs/getting-started.md +109 -25
- package/docs/harness-surfaces.md +69 -2
- package/docs/safety-and-autonomy.md +56 -12
- package/docs/troubleshooting.md +29 -8
- package/docs/tutorial.md +3 -2
- package/docs/work-and-browser.md +180 -14
- package/libexec/browser-operation.cjs +86 -2
- package/package.json +2 -2
- package/surfaces/skills/cockpit-browser.skill.md +7 -2
- package/surfaces/skills/cockpit-explain.skill.md +1 -1
- package/surfaces/skills/cockpit-fix.skill.md +1 -1
- package/surfaces/skills/cockpit-setup.skill.md +3 -3
- package/surfaces/skills/cockpit-work.skill.md +1 -1
- package/surfaces/skills/procedures/explain.md +9 -4
- package/surfaces/skills/procedures/fix.md +25 -11
- package/surfaces/skills/procedures/setup.md +113 -40
- package/surfaces/skills/procedures/work.md +45 -16
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import {} from '../schema.js';
|
|
3
3
|
import { sha256Hex } from '../sha256.js';
|
|
4
4
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, readdirSync, } from 'node:fs';
|
|
5
|
+
import { mkdir, open as openFile, rm } from 'node:fs/promises';
|
|
5
6
|
import { basename, delimiter, dirname, isAbsolute, join, parse as parsePath, relative, resolve, sep } from 'node:path';
|
|
6
7
|
import { isProxy } from 'node:util/types';
|
|
7
8
|
import { readBoundedFile } from '../bounded-read.js';
|
|
@@ -18,21 +19,29 @@ import { isGuardSurfaceTarget } from '../surfaces/harness-registry.js';
|
|
|
18
19
|
import { cockpitCommand } from '../fix-catalogue.js';
|
|
19
20
|
import { stripDevelopmentMarkers } from '../surfaces/development-markers.js';
|
|
20
21
|
import { decodeLifecycleRecord } from '../environment/application/lifecycle.js';
|
|
21
|
-
import {
|
|
22
|
+
import { selectLocalDockerDaemon } from '../environment/docker-daemon.js';
|
|
23
|
+
import { resolveDockerExecutable, resolveExecutablePath } from '../environment/executable.js';
|
|
24
|
+
import { discoverMachineStacks } from '../environment/reclaim.js';
|
|
22
25
|
import { consumeWorkApplicationAuthorities, prepareWorkApplicationAuthorities, projectWorkApplicationAuthorities, } from '../environment/generated-application/authorities.js';
|
|
23
26
|
import { prepareGeneratedEnvironmentCurrentness } from '../environment/generated-application/check.js';
|
|
24
27
|
import { createLifecycleCommandAdapters } from '../environment/lifecycle-composition.js';
|
|
25
28
|
import { createReadCommandAdapters } from '../environment/read-composition.js';
|
|
26
29
|
import { deriveEntrypoints, observeRetainedPortFamily } from '../environment/ports.js';
|
|
27
30
|
import { readState } from '../environment/state.js';
|
|
31
|
+
import { parseDiff } from '../testing/test-protection.js';
|
|
32
|
+
import { testFilePatterns, weakeningFindings } from '../testing/weakening.js';
|
|
28
33
|
import { runGate } from '../testing/gates.js';
|
|
29
|
-
import {
|
|
34
|
+
import { readSiblingCheckouts, missingServedCheckout, servedPath, servedSiblings } from '../environment/sibling-checkouts.js';
|
|
35
|
+
import { splitRepositoryPath } from '../repository-path.js';
|
|
36
|
+
import { parseCriteriaFile, parseWorkOrder, scopeMatcher, WORK_ORDER_ID } from './contract.js';
|
|
30
37
|
import { createWorkStore, outputTail, WORK_RECORD_DIRECTORY } from './store.js';
|
|
31
38
|
const issuedCompositions = new WeakSet();
|
|
32
39
|
const MAX_DOCUMENT_BYTES = 8 * 1_048_576;
|
|
33
40
|
const MAX_REPOSITORY_PATHS = 4_096;
|
|
34
41
|
const MAX_REPOSITORY_FILE_BYTES = 8 * 1_048_576;
|
|
35
42
|
const MAX_REPOSITORY_AGGREGATE_BYTES = 64 * 1_048_576;
|
|
43
|
+
const MAX_DECISION_RECORDS = 4_096;
|
|
44
|
+
const MAX_DECISION_RECORD_BYTES = 262_144;
|
|
36
45
|
// What the grader is shown of one changed file, and of the whole change.
|
|
37
46
|
const MAX_DIFF_BYTES = 65_536;
|
|
38
47
|
const MAX_CHANGE_BYTES = 1_048_576;
|
|
@@ -200,6 +209,34 @@ function readBounded(root, relativePath, maximum = MAX_DOCUMENT_BYTES) {
|
|
|
200
209
|
throw new RefusalError('contained file is missing');
|
|
201
210
|
return readAnchoredLeaf(anchored, maximum, 'contained file');
|
|
202
211
|
}
|
|
212
|
+
// Node has no openat, so a write cannot name its file relative to a directory descriptor. Every ancestor is
|
|
213
|
+
// anchored by descriptor instead, the leaf is opened without following a link, and nothing is written until
|
|
214
|
+
// the opened file is shown to be the single-link regular file at the confined name, under ancestors that are
|
|
215
|
+
// still the anchored directories: one swapped for a link while the file was opened fails that check.
|
|
216
|
+
async function openContainedForWrite(root, relativePath, flags) {
|
|
217
|
+
// No ancestor's contents are bound: the write itself changes the directory that holds the file.
|
|
218
|
+
const anchored = anchoredReadPath(root, relativePath, false, join(root, relativePath));
|
|
219
|
+
if (!anchored)
|
|
220
|
+
throw new RefusalError('contained path is missing');
|
|
221
|
+
try {
|
|
222
|
+
const handle = await openFile(anchored.target, flags | constants.O_WRONLY | constants.O_NOFOLLOW, 0o644);
|
|
223
|
+
try {
|
|
224
|
+
const opened = await handle.stat({ bigint: true });
|
|
225
|
+
const named = lstatSync(anchored.target, { bigint: true, throwIfNoEntry: false });
|
|
226
|
+
if (!opened.isFile() || opened.nlink !== 1n || opened.dev !== named?.dev || opened.ino !== named.ino || !readAnchorsCurrent(anchored.anchors)) {
|
|
227
|
+
throw new RefusalError('contained file or an ancestor changed while it was opened for writing; nothing was written');
|
|
228
|
+
}
|
|
229
|
+
return handle;
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
await handle.close();
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
closeReadAnchors(anchored.anchors);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
203
240
|
function readExactFile(path, maximum) {
|
|
204
241
|
const anchored = anchoredAbsoluteReadPath(path);
|
|
205
242
|
try {
|
|
@@ -395,11 +432,10 @@ function excludedRepositoryPath(path, excluded) {
|
|
|
395
432
|
? path === candidate.slice(0, -1) || path.startsWith(candidate)
|
|
396
433
|
: path === candidate);
|
|
397
434
|
}
|
|
398
|
-
function repositoryEnvironmentInstancePaths(repositoryRoot,
|
|
435
|
+
function repositoryEnvironmentInstancePaths(repositoryRoot, siblings) {
|
|
399
436
|
const paths = new Set();
|
|
400
437
|
for (const sibling of siblings) {
|
|
401
|
-
const
|
|
402
|
-
const prefix = relative(repositoryRoot, siblingRoot);
|
|
438
|
+
const prefix = relative(repositoryRoot, sibling.root);
|
|
403
439
|
if (isAbsolute(prefix) || prefix === '..' || prefix.startsWith(`..${sep}`))
|
|
404
440
|
continue;
|
|
405
441
|
for (const file of sibling.environmentFiles)
|
|
@@ -549,12 +585,13 @@ async function observeRepositorySubject(repository, excluded, directories, sensi
|
|
|
549
585
|
export function isWorkApplicationComposition(value) {
|
|
550
586
|
return value !== null && typeof value === 'object' && issuedCompositions.has(value);
|
|
551
587
|
}
|
|
552
|
-
function createWorkApplicationComposition(config, authorities) {
|
|
588
|
+
function createWorkApplicationComposition(config, authorities, served) {
|
|
553
589
|
if (!config || isProxy(config) || !boundedName(config.cockpitName) || !isAbsolute(config.cockpitRoot)
|
|
554
590
|
|| realpathSync(config.cockpitRoot) !== config.cockpitRoot || dirname(config.source.path) !== config.cockpitRoot
|
|
555
591
|
|| typeof config.siblingsRoot !== 'string' || !config.siblingsRoot || config.siblingsRoot.includes('\0')
|
|
556
592
|
|| !Array.isArray(config.siblings) || config.siblings.some(row => !boundedName(row?.slug) || typeof row?.dir !== 'string'
|
|
557
593
|
|| isAbsolute(row.dir) || row.dir !== '.' && row.dir.split(/[\\/]/).some((part) => !part || part === '.' || part === '..'))
|
|
594
|
+
|| config.siblings.some(row => !isAbsolute(served.get(row.slug)?.served ?? ''))
|
|
558
595
|
|| !authorities || isProxy(authorities))
|
|
559
596
|
throw new RefusalError('work application composition requires admitted source-bound configuration');
|
|
560
597
|
const root = config.cockpitRoot;
|
|
@@ -564,19 +601,21 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
564
601
|
const directories = authorities.executableDirectories ?? DEFAULT_EXECUTABLE_DIRECTORIES;
|
|
565
602
|
const browserAuthenticationTargets = new Set((config.browser?.authentication.states ?? []).map(row => resolve(root, row.path)));
|
|
566
603
|
// The cockpit's own repository is `.`; a sibling is named by its slug, and its paths read `<slug>:path`.
|
|
567
|
-
// A sibling
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
604
|
+
// A sibling is read where it is served, which `cockpit use` may have moved to a worktree, so a grade
|
|
605
|
+
// judges the code the stack runs. One served at the cockpit root is that same repository, under the cockpit's key.
|
|
606
|
+
const servedRoot = (slug) => servedPath(served, slug);
|
|
607
|
+
const siblingRoots = config.siblings.map(row => ({ root: servedRoot(row.slug), environmentFiles: row.environmentFiles }));
|
|
608
|
+
const repositories = [{ key: '.', identity: config.cockpitName, root }, ...config.siblings.flatMap(row => servedRoot(row.slug) === root ? []
|
|
609
|
+
: [{ key: row.slug, identity: `siblings/${row.slug}`, root: servedRoot(row.slug) }])]
|
|
610
|
+
.map(repository => ({ ...repository, sensitivePaths: repositoryEnvironmentInstancePaths(repository.root, siblingRoots) }));
|
|
611
|
+
const keyOf = (key) => config.siblings.some(row => row.slug === key && servedRoot(row.slug) === root) ? '.' : key;
|
|
572
612
|
const qualified = (key, path) => key === '.' ? path : `${key}:${path}`;
|
|
573
613
|
const located = (reference) => {
|
|
574
|
-
const
|
|
575
|
-
const key =
|
|
614
|
+
const { slug, path } = splitRepositoryPath(reference);
|
|
615
|
+
const key = slug === undefined ? '.' : keyOf(slug);
|
|
576
616
|
const repository = repositories.find(row => row.key === key);
|
|
577
617
|
if (!repository)
|
|
578
618
|
throw new RefusalError(`${reference} names no declared sibling ${key}`);
|
|
579
|
-
const path = colon < 0 ? reference : reference.slice(colon + 1);
|
|
580
619
|
admitRepositoryPath(repository.root, repository.identity, path, repository.sensitivePaths, browserAuthenticationTargets);
|
|
581
620
|
return { repository, path };
|
|
582
621
|
};
|
|
@@ -591,6 +630,100 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
591
630
|
throw error;
|
|
592
631
|
}
|
|
593
632
|
};
|
|
633
|
+
const readDocsFile = async (reference) => {
|
|
634
|
+
const { repository, path } = located(reference);
|
|
635
|
+
try {
|
|
636
|
+
return readBounded(repository.root, path, MAX_DECISION_RECORD_BYTES).toString('utf8');
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
if (hasErrorCode(error, 'ENOENT', 'ENOTDIR'))
|
|
640
|
+
return undefined;
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const decisionRecords = async () => {
|
|
645
|
+
const declared = config.docs?.decisions;
|
|
646
|
+
if (!declared)
|
|
647
|
+
return new Map();
|
|
648
|
+
const { repository, path } = located(declared);
|
|
649
|
+
let names;
|
|
650
|
+
try {
|
|
651
|
+
names = readdirSync(contained(repository.root, path, true));
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
655
|
+
return new Map();
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
const records = names.filter(name => name.endsWith('.md') && lstatSync(join(repository.root, path, name)).isFile()).sort();
|
|
659
|
+
if (records.length > MAX_DECISION_RECORDS)
|
|
660
|
+
throw new RefusalError(`[docs] decisions ${declared} holds more than ${MAX_DECISION_RECORDS} records, more than cockpit reads`);
|
|
661
|
+
return new Map(records.map(name => [`${declared}/${name}`, readBounded(repository.root, `${path}/${name}`, MAX_DECISION_RECORD_BYTES).toString('utf8')]));
|
|
662
|
+
};
|
|
663
|
+
// The only files cockpit writes outside .cockpit/: a decision record it scaffolds or marks superseded, and the index line.
|
|
664
|
+
// Each is written through a descriptor opened by openContainedForWrite; a replacement is written in place, as a
|
|
665
|
+
// rename would name its target by path again.
|
|
666
|
+
const writeContained = async (repository, path, flags, text, replace = false) => {
|
|
667
|
+
const handle = await openContainedForWrite(repository.root, path, flags);
|
|
668
|
+
try {
|
|
669
|
+
if (replace)
|
|
670
|
+
await handle.truncate(0);
|
|
671
|
+
await handle.writeFile(text);
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
await handle.close();
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
// A write that fails part way puts back what it changed, so a rerun does not number a second record or mark the old one twice.
|
|
678
|
+
const writeDecisionRecords = async (writes, indexLine) => {
|
|
679
|
+
const undo = [];
|
|
680
|
+
try {
|
|
681
|
+
for (const { reference, text, create } of writes) {
|
|
682
|
+
const { repository, path } = located(reference);
|
|
683
|
+
await mkdir(dirname(contained(repository.root, path, true)), { recursive: true });
|
|
684
|
+
const target = contained(repository.root, path, true);
|
|
685
|
+
if (create) {
|
|
686
|
+
try {
|
|
687
|
+
await writeContained(repository, path, constants.O_CREAT | constants.O_EXCL, text);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
if (hasErrorCode(error, 'EEXIST'))
|
|
691
|
+
throw new RefusalError(`${reference} already exists; rerun work decide, which takes the next free name`);
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
undo.push(() => rm(target, { force: true }));
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const previous = readBounded(repository.root, path, MAX_DECISION_RECORD_BYTES).toString('utf8');
|
|
698
|
+
// Queued before the write: an in-place write that fails after truncating has already changed the record.
|
|
699
|
+
undo.push(() => writeContained(repository, path, 0, previous, true));
|
|
700
|
+
await writeContained(repository, path, 0, text, true);
|
|
701
|
+
}
|
|
702
|
+
if (indexLine === undefined)
|
|
703
|
+
return;
|
|
704
|
+
if (!config.docs?.decisionsIndex)
|
|
705
|
+
throw new RefusalError('an index line was given but [docs] decisions_index is not declared');
|
|
706
|
+
const index = located(config.docs.decisionsIndex);
|
|
707
|
+
const current = readBounded(index.repository.root, index.path, MAX_DECISION_RECORD_BYTES).toString('utf8');
|
|
708
|
+
await writeContained(index.repository, index.path, constants.O_APPEND, `${current === '' || current.endsWith('\n') ? '' : '\n'}${indexLine}\n`);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
// Every step is tried, so one that fails does not leave the files after it changed.
|
|
712
|
+
const failed = [];
|
|
713
|
+
for (const step of undo.reverse()) {
|
|
714
|
+
try {
|
|
715
|
+
await step();
|
|
716
|
+
}
|
|
717
|
+
catch (undoError) {
|
|
718
|
+
failed.push(errorCause(undoError));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (failed.length)
|
|
722
|
+
throw new RefusalError(`${errorCause(error)}; putting back the decision records also failed (${failed.join('; ')}); `
|
|
723
|
+
+ 'compare the [docs] decisions directory with git diff and restore what changed with git restore');
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
};
|
|
594
727
|
const git = async (repository, args) => {
|
|
595
728
|
const observed = await executeBounded(repository.root, ['git', ...args], directories);
|
|
596
729
|
if (observed.kind !== 'executed' || observed.exitCode !== 0 || observed.truncated)
|
|
@@ -621,12 +754,12 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
621
754
|
throw error;
|
|
622
755
|
}
|
|
623
756
|
};
|
|
624
|
-
const browser = async (kind, role) => {
|
|
757
|
+
const browser = async (kind, role, route) => {
|
|
625
758
|
if (!config.browser)
|
|
626
759
|
throw new RefusalError('browser application projection is unavailable');
|
|
627
760
|
const authority = await resolveBrowser();
|
|
628
761
|
return { runtime: authority.runtime, client: authority.client, policy: authority.policy, kind,
|
|
629
|
-
...(authority.state ? { state: authority.state } : {}), ...(role ? { role } : {}) };
|
|
762
|
+
...(authority.state ? { state: authority.state } : {}), ...(role ? { role } : {}), ...(route ? { route } : {}) };
|
|
630
763
|
};
|
|
631
764
|
const gate = async (request) => {
|
|
632
765
|
if (!authorities.testing || !config.testing)
|
|
@@ -690,9 +823,10 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
690
823
|
throw new RefusalError(`${repository.identity} at ${repository.root} has no commit yet; commit once there`);
|
|
691
824
|
return [repository.key, head];
|
|
692
825
|
})));
|
|
826
|
+
const untrackedFiles = async () => new Map(await Promise.all(repositories.map(async (repository) => [repository.key, (await git(repository, ['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean)])));
|
|
693
827
|
// What changed since the base: committed, staged, unstaged and untracked files, each with its diff
|
|
694
828
|
// or new content. A credential-shaped or declared-instance path is listed but its content is withheld.
|
|
695
|
-
const
|
|
829
|
+
const changesWith = async (base, untrackedPaths) => {
|
|
696
830
|
const files = [];
|
|
697
831
|
let budget = MAX_CHANGE_BYTES;
|
|
698
832
|
// What sync generates is cockpit's, not the order's: the paths the ownership manifest lists.
|
|
@@ -716,7 +850,7 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
716
850
|
const rows = [];
|
|
717
851
|
for (let index = 0; index + 1 < tracked.length; index += 2)
|
|
718
852
|
rows.push([tracked[index], tracked[index + 1], false]);
|
|
719
|
-
for (const path of (
|
|
853
|
+
for (const path of untrackedPaths.get(repository.key))
|
|
720
854
|
rows.push(['A', path, true]);
|
|
721
855
|
for (const [status, path, untracked] of rows) {
|
|
722
856
|
if (repository.key === '.' && (excludedRepositoryPath(path, COCKPIT_WRITTEN) || generated.has(path)))
|
|
@@ -741,6 +875,40 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
741
875
|
}
|
|
742
876
|
return files;
|
|
743
877
|
};
|
|
878
|
+
// Test weakening since the base, per checkout: committed, staged and unstaged edits of tracked files,
|
|
879
|
+
// and new untracked test files, which a diff against the base does not list.
|
|
880
|
+
const weakeningWith = async (base, untracked) => {
|
|
881
|
+
const patterns = testFilePatterns(config.testing?.testFiles);
|
|
882
|
+
const isTest = scopeMatcher(patterns.tests);
|
|
883
|
+
const perRepository = await Promise.all(repositories.map(async (repository) => {
|
|
884
|
+
const from = base[repository.key] ?? 'HEAD';
|
|
885
|
+
const diff = await git(repository, ['-c', 'core.quotePath=true', 'diff', '--no-ext-diff', '--no-textconv', '--no-color', '--text', '--find-renames', '--unified=0', from, '--']);
|
|
886
|
+
const files = parseDiff(diff);
|
|
887
|
+
for (const path of untracked.get(repository.key)) {
|
|
888
|
+
if (!isTest(path))
|
|
889
|
+
continue;
|
|
890
|
+
let content;
|
|
891
|
+
try {
|
|
892
|
+
admitRepositoryPath(repository.root, repository.identity, path, repository.sensitivePaths, browserAuthenticationTargets);
|
|
893
|
+
content = readBounded(repository.root, path, MAX_DIFF_BYTES).toString('utf8');
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
if (error instanceof RefusalError)
|
|
897
|
+
continue;
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
files.push({ old: '/dev/null', next: path, hunk: true, removed: false, removedLines: [], renamed: false, deleted: false, added: content.split('\n') });
|
|
901
|
+
}
|
|
902
|
+
return weakeningFindings(files, patterns).map(row => ({ ...row, repository: repository.key }));
|
|
903
|
+
}));
|
|
904
|
+
return { patterns, findings: perRepository.flat() };
|
|
905
|
+
};
|
|
906
|
+
const testWeakening = async (base) => weakeningWith(base, await untrackedFiles());
|
|
907
|
+
const review = async (base) => {
|
|
908
|
+
const untracked = await untrackedFiles();
|
|
909
|
+
const [changed, weakening] = await Promise.all([changesWith(base, untracked), weakeningWith(base, untracked)]);
|
|
910
|
+
return { changes: changed, weakening };
|
|
911
|
+
};
|
|
744
912
|
const gradeIndependently = async (subject) => {
|
|
745
913
|
let evaluator;
|
|
746
914
|
try {
|
|
@@ -764,13 +932,29 @@ function createWorkApplicationComposition(config, authorities) {
|
|
|
764
932
|
return { refusal: { code: ENVIRONMENT_CODES.EVALUATOR_ANSWER_INVALID, message: read.problem, fix: 'rerun the command; if the answer is refused again, list another evaluator first in work.evaluators' } };
|
|
765
933
|
return { criteria: read.criteria, evaluator: { tool: result.tool, fallbacks: result.fallbacks } };
|
|
766
934
|
};
|
|
935
|
+
const containers = async () => {
|
|
936
|
+
const env = authorities.env;
|
|
937
|
+
const docker = await resolveDockerExecutable(env);
|
|
938
|
+
if (docker.state !== 'available')
|
|
939
|
+
return { state: 'no-docker', reason: docker.reason };
|
|
940
|
+
const daemon = await selectLocalDockerDaemon(env, 30_000, executeProbe, { command: 'work recover',
|
|
941
|
+
localOnly: 'a lock holder can be looked for only among the containers on this machine' });
|
|
942
|
+
if (daemon.kind === 'refusal')
|
|
943
|
+
return { state: 'unavailable', reason: daemon.diagnostic.message };
|
|
944
|
+
const listed = await discoverMachineStacks({ executable: docker.value, context: daemon.dockerContext, ...env.DOCKER_HOST === undefined ? {} : { host: env.DOCKER_HOST },
|
|
945
|
+
cwd: root, env: { PATH: env.PATH ?? '' }, timeoutMs: 30_000 }, executeProbe);
|
|
946
|
+
if (!listed)
|
|
947
|
+
return { state: 'unavailable', reason: `the containers cockpit labels could not be listed on Docker context ${daemon.dockerContext}; start Docker, or select the local context that runs cockpit stacks` };
|
|
948
|
+
return { state: 'listed', context: daemon.dockerContext, containers: listed.resources.filter(row => row.kind === 'container').map(({ id, name, running }) => ({ id, name, running })) };
|
|
949
|
+
};
|
|
767
950
|
const composition = {
|
|
768
951
|
policy: config.policy, caps: { wip: config.wipCap, attempts: config.attemptCap }, env: authorities.env,
|
|
769
952
|
store: createWorkStore(root), recordedPolicy: () => readRecordedPolicy(root), orderIds, readOrder,
|
|
770
953
|
readCriteriaFile: async (path) => parseCriteriaFile(readBounded(root, path, 65_536)),
|
|
771
954
|
sourceDigests: async (order) => Object.fromEntries(order.criteria.flatMap(row => row.source ? [row.source] : [])
|
|
772
955
|
.map(source => [source, sha256Hex(readSource(source))])),
|
|
773
|
-
heads, runCheck,
|
|
956
|
+
heads, runCheck, evaluate: gradeIndependently, testWeakening, review, browser, gate,
|
|
957
|
+
...config.docs ? { docs: config.docs } : {}, decisionRecords, readDocsFile, writeDecisionRecords, containers,
|
|
774
958
|
};
|
|
775
959
|
issuedCompositions.add(composition);
|
|
776
960
|
return composition;
|
|
@@ -970,14 +1154,22 @@ export async function createConfiguredWorkApplicationComposition(input, options
|
|
|
970
1154
|
namespace: browserAuthority.namespace, stackId: loaded.result.stackId, ownerId: record.value.holderId,
|
|
971
1155
|
} } : {}) };
|
|
972
1156
|
};
|
|
973
|
-
|
|
1157
|
+
// Work orders and gates read each sibling where `cockpit use` serves it, as the stack does.
|
|
1158
|
+
const recorded = await readSiblingCheckouts(config.cockpitRoot);
|
|
1159
|
+
if (recorded.kind !== 'complete')
|
|
1160
|
+
return { kind: 'refusal', reason: `${recorded.diagnostic.code}: ${recorded.diagnostic.message}; ${recorded.diagnostic.fix}` };
|
|
1161
|
+
const served = servedSiblings(config.cockpitRoot, { cockpit: { siblings_root: config.siblingsRoot }, sibling: config.siblings }, recorded.checkouts);
|
|
1162
|
+
const gone = await missingServedCheckout(served);
|
|
1163
|
+
if (gone)
|
|
1164
|
+
return { kind: 'refusal', reason: `${gone.code}: ${gone.message}; ${gone.fix}` };
|
|
1165
|
+
const siblingRoots = config.siblings.map(row => ({ root: servedPath(served, row.slug), environmentFiles: row.environmentFiles }));
|
|
974
1166
|
const gateBrowserAuthenticationTargets = new Set((config.browser?.authentication.states ?? []).map(row => resolve(config.cockpitRoot, row.path)));
|
|
975
1167
|
const gateRepositories = [
|
|
976
1168
|
{ identity: config.cockpitName, root: config.cockpitRoot,
|
|
977
|
-
sensitivePaths: repositoryEnvironmentInstancePaths(config.cockpitRoot,
|
|
1169
|
+
sensitivePaths: repositoryEnvironmentInstancePaths(config.cockpitRoot, siblingRoots),
|
|
978
1170
|
forbiddenTargets: gateBrowserAuthenticationTargets },
|
|
979
|
-
...
|
|
980
|
-
sensitivePaths: repositoryEnvironmentInstancePaths(
|
|
1171
|
+
...siblingRoots.map((row, index) => ({ identity: `siblings/${config.siblings[index].slug}`, root: row.root,
|
|
1172
|
+
sensitivePaths: repositoryEnvironmentInstancePaths(row.root, siblingRoots),
|
|
981
1173
|
forbiddenTargets: gateBrowserAuthenticationTargets })),
|
|
982
1174
|
];
|
|
983
1175
|
const reads = createReadCommandAdapters({ configPath: captured.configPath, env: captured.env,
|
|
@@ -1001,7 +1193,7 @@ export async function createConfiguredWorkApplicationComposition(input, options
|
|
|
1001
1193
|
...(config.browser ? { resolveBrowser } : {}),
|
|
1002
1194
|
testing: { registry: authority.testing.registry,
|
|
1003
1195
|
dependencies: productGateDependencies(config.cockpitRoot, config.source, gateRepositories, declaredEnvironment, executableDirectories), runtime: gateRuntime },
|
|
1004
|
-
});
|
|
1196
|
+
}, served);
|
|
1005
1197
|
return { kind: 'complete', composition };
|
|
1006
1198
|
}
|
|
1007
1199
|
catch (error) {
|
|
@@ -34,7 +34,7 @@ export type ParsedFile<T> = Readonly<{
|
|
|
34
34
|
/** Parses `workorders/<id>.md`; the id the file declares must be the name it is stored under. */
|
|
35
35
|
export declare function parseWorkOrder(bytes: Uint8Array, fileId: string): ParsedFile<WorkOrder>;
|
|
36
36
|
/** The quick-path file `cockpit grade --criteria` reads: front-matter with criteria, and optionally scope and gates. */
|
|
37
|
-
|
|
37
|
+
declare const CriteriaFile: z.ZodObject<{
|
|
38
38
|
criteria: z.ZodArray<z.ZodObject<{
|
|
39
39
|
id: z.ZodString;
|
|
40
40
|
text: z.ZodString;
|
|
@@ -51,11 +51,27 @@ export declare const CriteriaFile: z.ZodObject<{
|
|
|
51
51
|
export declare function parseCriteriaFile(bytes: Uint8Array): ParsedFile<z.infer<typeof CriteriaFile>>;
|
|
52
52
|
/** The digest an acceptance binds: the contract, each cited source's bytes, and the base commits. */
|
|
53
53
|
export declare function workOrderDigest(order: WorkOrder, sources: Readonly<Record<string, string>>, base: Readonly<Record<string, string>>): string;
|
|
54
|
+
/** One part of a contract an amendment changed in a way that may lower the bar, as it read before and after. */
|
|
55
|
+
export type AmendmentReview = Readonly<{
|
|
56
|
+
field: string;
|
|
57
|
+
before: string;
|
|
58
|
+
after: string;
|
|
59
|
+
}>;
|
|
60
|
+
type ContractAmendment = Readonly<{
|
|
61
|
+
removed: readonly string[];
|
|
62
|
+
review: readonly AmendmentReview[];
|
|
63
|
+
}>;
|
|
54
64
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
65
|
+
* How a changed contract moves the bar. Dropping an accepted criterion or gate lowers it in every
|
|
66
|
+
* reading, so it is `removed`. A rewritten criterion, a widened scope, a dropped or reworded non-goal
|
|
67
|
+
* or a new objective may lower it or only clarify it: no byte comparison can tell which, so each is
|
|
68
|
+
* returned for review with its text before and after. Adding a criterion or gate, narrowing scope and
|
|
69
|
+
* adding a non-goal only raise the bar.
|
|
58
70
|
*/
|
|
59
|
-
export declare function
|
|
60
|
-
|
|
71
|
+
export declare function contractAmendment(accepted: WorkOrder, changed: WorkOrder): ContractAmendment;
|
|
72
|
+
/** The review fields a `checks` grade enforces: the criteria's checks and the scope. Objective and non-goals bind only where a model grades. */
|
|
73
|
+
export declare const checksBindsField: (field: string) => boolean;
|
|
74
|
+
/** Whether a path falls in `scope`, with each glob compiled once for the many paths a caller tests. */
|
|
75
|
+
export declare function scopeMatcher(scope: readonly string[]): (path: string) => boolean;
|
|
76
|
+
export declare const scopeMatches: (scope: readonly string[], path: string) => boolean;
|
|
61
77
|
export {};
|
|
@@ -5,13 +5,12 @@
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import { parseDocument } from 'yaml';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
+
import { REPOSITORY_PATH } from '../repository-path.js';
|
|
8
9
|
// One inert word, never a path or an option: the CLI, the guard and the store each admit it before it names a file.
|
|
9
10
|
export const WORK_ORDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
11
|
const WORK_ORDER_LIMITS = Object.freeze({ bytes: 65_536, criteria: 12, scope: 64, textBytes: 2_048, arguments: 32, argumentBytes: 2_048 });
|
|
11
12
|
const PRINTABLE = /^[^\x00-\x08\x0b-\x1f\x7f]*$/;
|
|
12
13
|
const text = z.string().trim().min(1).refine(value => Buffer.byteLength(value) <= WORK_ORDER_LIMITS.textBytes && PRINTABLE.test(value), `at most ${WORK_ORDER_LIMITS.textBytes} bytes without control characters`);
|
|
13
|
-
// `path` in the cockpit's own repository, or `<sibling slug>:path` in a sibling; never absolute or climbing.
|
|
14
|
-
const REPOSITORY_PATH = /^(?:[a-z0-9][a-z0-9-]{0,63}:)?(?![/])(?!.*(?:^|[/:])\.\.(?:\/|$))[A-Za-z0-9._@+\-/*?]+$/;
|
|
15
14
|
const repositoryPath = z.string().max(1_024).regex(REPOSITORY_PATH, 'a relative path, optionally <sibling slug>:path, without .. or a leading /');
|
|
16
15
|
const sourcePath = repositoryPath.refine(value => !/[*?]/.test(value), 'a source names one file, without wildcards');
|
|
17
16
|
const argument = z.string().min(1).refine(value => Buffer.byteLength(value) <= WORK_ORDER_LIMITS.argumentBytes && !/[\x00-\x1f\x7f]/.test(value), 'a bounded argument without control characters');
|
|
@@ -77,7 +76,7 @@ export function parseWorkOrder(bytes, fileId) {
|
|
|
77
76
|
return { ok: true, value: parsed.data };
|
|
78
77
|
}
|
|
79
78
|
/** The quick-path file `cockpit grade --criteria` reads: front-matter with criteria, and optionally scope and gates. */
|
|
80
|
-
|
|
79
|
+
const CriteriaFile = z.object({ criteria: Criteria, scope: z.array(repositoryPath).max(WORK_ORDER_LIMITS.scope).default([]),
|
|
81
80
|
gates: z.array(z.string().regex(/^[a-z][a-z0-9-]{0,63}$/)).max(16).default([]), objective: text.optional() }).strict();
|
|
82
81
|
export function parseCriteriaFile(bytes) {
|
|
83
82
|
const read = frontMatter(bytes);
|
|
@@ -97,26 +96,45 @@ function canonical(value) {
|
|
|
97
96
|
export function workOrderDigest(order, sources, base) {
|
|
98
97
|
return createHash('sha256').update(JSON.stringify(canonical({ order, sources, base }))).digest('hex');
|
|
99
98
|
}
|
|
99
|
+
const criterionText = (row) => `${row.text}${row.check ? ` [check: ${JSON.stringify(row.check)}]` : ''}${row.source ? ` [source: ${row.source}]` : ''}`;
|
|
100
100
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* How a changed contract moves the bar. Dropping an accepted criterion or gate lowers it in every
|
|
102
|
+
* reading, so it is `removed`. A rewritten criterion, a widened scope, a dropped or reworded non-goal
|
|
103
|
+
* or a new objective may lower it or only clarify it: no byte comparison can tell which, so each is
|
|
104
|
+
* returned for review with its text before and after. Adding a criterion or gate, narrowing scope and
|
|
105
|
+
* adding a non-goal only raise the bar.
|
|
104
106
|
*/
|
|
105
|
-
export function
|
|
106
|
-
|
|
107
|
+
export function contractAmendment(accepted, changed) {
|
|
108
|
+
const removed = [...accepted.criteria.filter(before => !changed.criteria.some(row => row.id === before.id)).map(row => `${row.id} is removed`),
|
|
109
|
+
...accepted.gates.filter(gate => !changed.gates.includes(gate)).map(gate => `gate ${gate} is removed`)];
|
|
110
|
+
const review = accepted.criteria.flatMap(before => {
|
|
107
111
|
const after = changed.criteria.find(row => row.id === before.id);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return JSON.stringify(canonical(after)) === JSON.stringify(canonical(before)) ? [] : [`${before.id} is rewritten`];
|
|
112
|
+
return after && JSON.stringify(canonical(after)) !== JSON.stringify(canonical(before))
|
|
113
|
+
? [{ field: `criteria.${before.id}`, before: criterionText(before), after: criterionText(after) }] : [];
|
|
111
114
|
});
|
|
115
|
+
if (changed.scope.some(entry => !accepted.scope.includes(entry)))
|
|
116
|
+
review.push({ field: 'scope', before: accepted.scope.join(', '), after: changed.scope.join(', ') });
|
|
117
|
+
if (accepted.non_goals.some(goal => !changed.non_goals.includes(goal)))
|
|
118
|
+
review.push({ field: 'non_goals', before: accepted.non_goals.join('; '), after: changed.non_goals.join('; ') });
|
|
119
|
+
if (accepted.objective !== changed.objective)
|
|
120
|
+
review.push({ field: 'objective', before: accepted.objective, after: changed.objective });
|
|
121
|
+
return { removed, review };
|
|
112
122
|
}
|
|
123
|
+
/** The review fields a `checks` grade enforces: the criteria's checks and the scope. Objective and non-goals bind only where a model grades. */
|
|
124
|
+
export const checksBindsField = (field) => field === 'scope' || field.startsWith('criteria.');
|
|
113
125
|
// `**` crosses directories, `*` and `?` stay within one; a plain entry also covers what is below it.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
/** Whether a path falls in `scope`, with each glob compiled once for the many paths a caller tests. */
|
|
127
|
+
export function scopeMatcher(scope) {
|
|
128
|
+
const tests = scope.map((entry) => {
|
|
129
|
+
if (!/[*?]/.test(entry)) {
|
|
130
|
+
const directory = `${entry.replace(/\/$/, '')}/`;
|
|
131
|
+
return path => path === entry || path.startsWith(directory);
|
|
132
|
+
}
|
|
118
133
|
const pattern = entry.split(/(\*\*\/?|\*|\?)/).map(part => part === '**' || part === '**/' ? '(?:.*/)?' + (part === '**' ? '.*' : '')
|
|
119
134
|
: part === '*' ? '[^/]*' : part === '?' ? '[^/]' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('');
|
|
120
|
-
|
|
135
|
+
const compiled = new RegExp(`^${pattern}$`);
|
|
136
|
+
return path => compiled.test(path);
|
|
121
137
|
});
|
|
138
|
+
return path => tests.some(test => test(path));
|
|
122
139
|
}
|
|
140
|
+
export const scopeMatches = (scope, path) => scopeMatcher(scope)(path);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type RecordName = Readonly<{
|
|
2
|
+
name: string;
|
|
3
|
+
number?: string;
|
|
4
|
+
}>;
|
|
5
|
+
/** The next record's file name: the next free number, at least four digits wide, unless the directory names records by date. */
|
|
6
|
+
export declare function nextRecordName(existing: readonly string[], title: string, today: string): RecordName;
|
|
7
|
+
/** A single-line title, safe inside a heading and a Markdown link label. */
|
|
8
|
+
export declare function recordTitle(text: string): string;
|
|
9
|
+
export declare function scaffoldRecord(input: Readonly<{
|
|
10
|
+
title: string;
|
|
11
|
+
number?: string;
|
|
12
|
+
workOrder: string;
|
|
13
|
+
supersedes?: string;
|
|
14
|
+
}>): string;
|
|
15
|
+
export declare const holdsPlaceholders: (text: string) => boolean;
|
|
16
|
+
/** The work orders a record cites, from a `Work order:` line or a `work_order:` front-matter field. */
|
|
17
|
+
export declare function citedOrders(text: string): readonly string[];
|
|
18
|
+
/**
|
|
19
|
+
* Whether a record names a path inside `scope`. A path-like word is one with a slash; a record kept
|
|
20
|
+
* in a sibling names that sibling's paths without its prefix, so `prefix` qualifies them.
|
|
21
|
+
*/
|
|
22
|
+
export declare function namesScope(text: string, scope: readonly string[], prefix: string): boolean;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Owns the decision records a work order cites, read and written as text: how a new record is named,
|
|
2
|
+
// the template cockpit scaffolds, whether a record still holds that template's placeholders, which
|
|
3
|
+
// order it cites, and which paths it names. The integrity of the whole directory (duplicate numbers,
|
|
4
|
+
// an index that misses a file) is the docs check's; this module reads only what an order needs.
|
|
5
|
+
import { isDatedRecordName, recordNumber } from '../docs/decision-record.js';
|
|
6
|
+
import { splitRepositoryPath } from '../repository-path.js';
|
|
7
|
+
import { scopeMatcher, WORK_ORDER_ID } from './contract.js';
|
|
8
|
+
// Every placeholder the template writes carries this, so a record nobody filled in is detectable.
|
|
9
|
+
const PLACEHOLDER = 'cockpit: replace this';
|
|
10
|
+
/** The next record's file name: the next free number, at least four digits wide, unless the directory names records by date. */
|
|
11
|
+
export function nextRecordName(existing, title, today) {
|
|
12
|
+
const records = existing.filter(name => name.endsWith('.md'));
|
|
13
|
+
const numbers = records.flatMap(name => recordNumber(name) ?? []);
|
|
14
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64).replace(/-+$/, '') || 'decision';
|
|
15
|
+
if (!numbers.length && records.some(isDatedRecordName)) {
|
|
16
|
+
// Two decisions with one title on one day would otherwise share a name.
|
|
17
|
+
let name = `${today}-${slug}.md`;
|
|
18
|
+
for (let suffix = 2; records.includes(name); suffix++)
|
|
19
|
+
name = `${today}-${slug}-${suffix}.md`;
|
|
20
|
+
return { name };
|
|
21
|
+
}
|
|
22
|
+
const next = numbers.reduce((max, value) => Math.max(max, Number(value)), 0) + 1;
|
|
23
|
+
const number = String(next).padStart(Math.max(4, ...numbers.map(value => value.length)), '0');
|
|
24
|
+
return { name: `${number}-${slug}.md`, number };
|
|
25
|
+
}
|
|
26
|
+
/** A single-line title, safe inside a heading and a Markdown link label. */
|
|
27
|
+
export function recordTitle(text) {
|
|
28
|
+
return text.replace(/\s+/g, ' ').trim().replace(/[[\]]/g, '');
|
|
29
|
+
}
|
|
30
|
+
export function scaffoldRecord(input) {
|
|
31
|
+
return [`# ${input.number ? `${input.number}. ` : ''}${input.title}`, '', 'Status: accepted', `Work order: ${input.workOrder}`,
|
|
32
|
+
...input.supersedes ? [`Supersedes: ${input.supersedes}`] : [], '',
|
|
33
|
+
'## Context', '', `<!-- ${PLACEHOLDER} with what made a decision necessary. -->`, '',
|
|
34
|
+
'## Decision', '', `<!-- ${PLACEHOLDER} with what was decided. -->`, '',
|
|
35
|
+
'## Consequences', '', `<!-- ${PLACEHOLDER} with what becomes easier or harder because of it. -->`, ''].join('\n');
|
|
36
|
+
}
|
|
37
|
+
export const holdsPlaceholders = (text) => text.includes(PLACEHOLDER);
|
|
38
|
+
/** The work orders a record cites, from a `Work order:` line or a `work_order:` front-matter field. */
|
|
39
|
+
export function citedOrders(text) {
|
|
40
|
+
return [...text.matchAll(/^\**work[ _-]order\**:\**[ \t]*(.+)$/gim)]
|
|
41
|
+
.flatMap(match => match[1].split(/[\s,`]+/)).filter(id => id && WORK_ORDER_ID.test(id));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether a record names a path inside `scope`. A path-like word is one with a slash; a record kept
|
|
45
|
+
* in a sibling names that sibling's paths without its prefix, so `prefix` qualifies them.
|
|
46
|
+
*/
|
|
47
|
+
export function namesScope(text, scope, prefix) {
|
|
48
|
+
const inScope = scopeMatcher(scope);
|
|
49
|
+
for (const match of text.matchAll(/(?:^|[\s`(<"'[])((?:[a-z0-9][a-z0-9-]{0,63}:)?[A-Za-z0-9._@+-]+(?:\/[A-Za-z0-9._@+-]*)+)/g)) {
|
|
50
|
+
const path = match[1].replace(/^\.\//, '').replace(/[.,;:]+$/, '');
|
|
51
|
+
if (inScope(splitRepositoryPath(path).slug === undefined ? `${prefix}${path}` : path))
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|