devflow-kit 3.9.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/README.md +1 -1
- package/dist/agents/code.md +6 -6
- package/dist/agents/fix.md +6 -6
- package/dist/agents/harden.md +7 -6
- package/dist/agents/refactor.md +6 -6
- package/dist/cli/commands/ambient.js +10 -2
- package/dist/cli/commands/backup-session.js +131 -0
- package/dist/cli/commands/compliance-prompts.js +9 -4
- package/dist/cli/commands/compliance.js +184 -24
- package/dist/cli/commands/curation.js +23 -10
- package/dist/cli/commands/gate-level-prompts.js +39 -25
- package/dist/cli/commands/gates.js +5 -7
- package/dist/cli/commands/init.js +169 -184
- package/dist/cli/commands/install-report.js +129 -0
- package/dist/cli/commands/legacy-sweep.js +159 -0
- package/dist/cli/commands/proxy.js +6 -1
- package/dist/cli/commands/rules.js +75 -21
- package/dist/cli/commands/skills.js +42 -9
- package/dist/cli/commands/tracker.js +9 -3
- package/dist/cli/commands/uninstall.js +339 -75
- package/dist/commands/bug-analysis.md +69 -120
- package/dist/commands/dynamic-build.md +23 -23
- package/dist/commands/implement.md +46 -32
- package/dist/commands/release.md +1 -1
- package/dist/commands/resolve.md +35 -31
- package/dist/commands/self-review.md +8 -7
- package/dist/compliance-off/agents/git.md +120 -0
- package/dist/compliance-off/agents/triage.md +180 -0
- package/dist/compliance-off/commands/bug-analysis.md +398 -0
- package/dist/compliance-off/commands/code-review.md +520 -0
- package/dist/compliance-off/commands/dynamic-build.md +1251 -0
- package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
- package/dist/compliance-off/commands/implement.md +736 -0
- package/dist/compliance-off/commands/plan.md +671 -0
- package/dist/compliance-off/commands/release.md +309 -0
- package/dist/compliance-off/commands/resolve.md +742 -0
- package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
- package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
- package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
- package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
- package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
- package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
- package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
- package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
- package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
- package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
- package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
- package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
- package/dist/core/agent-models.js +19 -4
- package/dist/core/assets.js +126 -17
- package/dist/core/compliance-off-files.js +27 -0
- package/dist/core/compliance.js +14 -0
- package/dist/core/curation-variants.js +460 -112
- package/dist/core/flags.js +2 -2
- package/dist/core/fs-atomic.js +5 -1
- package/dist/core/installed-variants.js +63 -0
- package/dist/core/manifest.js +48 -5
- package/dist/core/mds-variants.js +700 -3
- package/dist/core/orphan-sweep.js +29 -2
- package/dist/core/path-set.js +15 -0
- package/dist/core/plugins.js +68 -10
- package/dist/core/reference-sweep.js +42 -25
- package/dist/core/retired-shipped-names.js +278 -0
- package/dist/curation-compliance-off/agents/triage.md +176 -0
- package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
- package/dist/curation-compliance-off/commands/code-review.md +481 -0
- package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
- package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
- package/dist/curation-compliance-off/commands/implement.md +689 -0
- package/dist/curation-compliance-off/commands/plan.md +631 -0
- package/dist/curation-compliance-off/commands/release.md +279 -0
- package/dist/curation-compliance-off/commands/resolve.md +692 -0
- package/dist/curation-off/agents/code.md +6 -6
- package/dist/curation-off/agents/fix.md +6 -6
- package/dist/curation-off/agents/harden.md +7 -6
- package/dist/curation-off/agents/refactor.md +6 -6
- package/dist/curation-off/commands/bug-analysis.md +69 -119
- package/dist/curation-off/commands/dynamic-build.md +23 -23
- package/dist/curation-off/commands/implement.md +46 -32
- package/dist/curation-off/commands/release.md +1 -1
- package/dist/curation-off/commands/resolve.md +35 -31
- package/dist/curation-off/commands/self-review.md +8 -7
- package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
- package/dist/targets/claude-code/behavior-install.js +23 -9
- package/dist/targets/claude-code/charter-render.js +374 -0
- package/dist/targets/claude-code/compliance-install.js +62 -17
- package/dist/targets/claude-code/compliance-off-files.js +249 -0
- package/dist/targets/claude-code/curation-install.js +632 -117
- package/dist/targets/claude-code/folder-swap.js +142 -0
- package/dist/targets/claude-code/install-backup.js +780 -0
- package/dist/targets/claude-code/installer.js +795 -199
- package/dist/targets/claude-code/language-stamp.js +6 -0
- package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
- package/dist/targets/claude-code/linked-folder.js +176 -0
- package/dist/targets/claude-code/reference-converge.js +131 -0
- package/dist/targets/claude-code/rules-folder.js +34 -0
- package/dist/targets/claude-code/shipped-set.js +160 -0
- package/dist/targets/claude-code/tracker-install.js +21 -8
- package/dist/targets/claude-code/tracker-servers.js +20 -6
- package/dist/targets/claude-code/workflow-folder-install.js +251 -0
- package/dist/variant-axes.json +109 -0
- package/package.json +1 -1
- package/src/assets/agents/_partials/_code.mds +6 -6
- package/src/assets/agents/evaluate.md +1 -1
- package/src/assets/agents/git.mds +32 -0
- package/src/assets/agents/harden.mds +7 -6
- package/src/assets/agents/test.md +21 -12
- package/src/assets/agents/triage.mds +2 -0
- package/src/assets/agents/validate.md +10 -10
- package/src/assets/commands/_partials/_compliance.mds +5 -3
- package/src/assets/commands/_partials/_engine.mds +9 -8
- package/src/assets/commands/_partials/_publication.mds +10 -1
- package/src/assets/commands/bug-analysis.mds +80 -126
- package/src/assets/commands/code-review.mds +43 -0
- package/src/assets/commands/dynamic-build.mds +109 -15
- package/src/assets/commands/dynamic-tickets.mds +13 -0
- package/src/assets/commands/implement.mds +145 -33
- package/src/assets/commands/plan.mds +19 -0
- package/src/assets/commands/release.mds +50 -1
- package/src/assets/commands/resolve.mds +217 -32
- package/src/assets/commands/self-review.mds +8 -7
- package/src/assets/mds/git/_ops.mds +23 -0
- package/src/assets/mds/git/_ops_steps.mds +17 -0
- package/src/assets/mds/git/_pr.mds +1 -1
- package/src/assets/mds/git/_references.mds +6 -0
- package/src/assets/mds/tracker/_common.mds +4 -0
- package/src/assets/mds/tracker/_github.mds +42 -0
- package/src/assets/mds/tracker/_jira.mds +43 -0
- package/src/assets/mds/tracker/_linear.mds +43 -0
- package/src/assets/rules/quality.md +2 -0
- package/src/assets/scripts/ci-wait.cjs +204 -126
- package/src/assets/scripts/hooks/assets/implement-installed +1 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
- package/src/assets/scripts/hooks/preamble +18 -8
- package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
- package/src/assets/scripts/pr-evidence.cjs +41 -2
- package/src/assets/scripts/verify-evidence.cjs +408 -56
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
- package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
- package/src/assets/skills/docs-framework/SKILL.md +5 -2
- package/src/assets/skills/git/SKILL.md +3 -3
- package/src/assets/skills/git/references/github-api.md +2 -139
- package/src/assets/skills/git/references/github-posting.md +158 -0
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +1 -1
- package/src/assets/skills/review-methodology/references/report-template.md +1 -2
- package/src/assets/skills/testing/SKILL.md +2 -4
|
@@ -21,13 +21,26 @@
|
|
|
21
21
|
* 5. Which files does the shipped registry produce, flattened into the manifest
|
|
22
22
|
* an installer converges to? (generatedReferenceManifest — the one answer
|
|
23
23
|
* that asserts instead of returning a Result; see the function for why.)
|
|
24
|
+
* The same question for a machine with compliance off is answered by
|
|
25
|
+
* installedReferenceManifest({ compliance: false }): the manifest minus the
|
|
26
|
+
* references of the posting operations (POSTING_OPS) and the publication gate.
|
|
27
|
+
* Two more questions serve the compliance-off references the build writes beside
|
|
28
|
+
* the compliance-on ones: which relative paths a compliance-off install omits
|
|
29
|
+
* (complianceOffOmittedRelPaths), and what each compliance arm of one operation's
|
|
30
|
+
* section keeps (splitSectionArms).
|
|
31
|
+
* A last group serves the workflow skill folders: which hosts make up a folder and
|
|
32
|
+
* which files each variant key holds (WORKFLOW_FOLDERS, workflowFolderFiles), and the
|
|
33
|
+
* build id that ties the files of one folder and key together (workflowBuildId,
|
|
34
|
+
* stampBuildId, unstampBuildId, checkFolderBuildIds, verifyFolderBuildId).
|
|
24
35
|
* It still performs no I/O and no iteration over the filesystem.
|
|
25
36
|
*
|
|
26
37
|
* The `-variants` in the filename names the variant-expansion entry point below
|
|
27
38
|
* (DR-16), which lives next to the validation it depends on.
|
|
28
39
|
*/
|
|
40
|
+
import { createHash } from 'crypto';
|
|
29
41
|
import * as path from 'path';
|
|
30
42
|
import { isContainedIn } from './paths.js';
|
|
43
|
+
import { COMPLIANCE_OFF_OUTPUT_DIR, OFF_OUTPUT_DIRS, machineVariantKey, splitVariants, } from './curation-variants.js';
|
|
31
44
|
function Ok(value) {
|
|
32
45
|
return { ok: true, value };
|
|
33
46
|
}
|
|
@@ -101,13 +114,42 @@ export function validateContractOutputName(name) {
|
|
|
101
114
|
return Ok(name);
|
|
102
115
|
return Err(inner.error.kind === 'empty' ? { kind: 'empty' } : { ...inner.error, name });
|
|
103
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The one name the core host of a workflow skill folder may emit: the stem of
|
|
119
|
+
* `SKILL.md`, the file name a skill's entry point has by convention.
|
|
120
|
+
*/
|
|
121
|
+
export const WORKFLOW_SKILL_ENTRY_NAME = 'SKILL';
|
|
122
|
+
/**
|
|
123
|
+
* Validate the emitted basename of a workflow skill folder's CORE host: exactly the
|
|
124
|
+
* literal {@link WORKFLOW_SKILL_ENTRY_NAME}.
|
|
125
|
+
*
|
|
126
|
+
* D-WORKFLOW-SKILL-NAME: a THIRD name rule, beside validateOutputName and
|
|
127
|
+
* validateContractOutputName, and OUTPUT_NAME_RE stays as it is. The literal is
|
|
128
|
+
* uppercase, which the ordinary rule refuses on purpose for every command, agent and
|
|
129
|
+
* reference, so relaxing the shared rule would have admitted `Implement.md` as a
|
|
130
|
+
* command name too, across all four build destinations, to buy a name only this one
|
|
131
|
+
* file needs. A closed set of one is a literal comparison, not a pattern.
|
|
132
|
+
*
|
|
133
|
+
* Every other name is refused with the ordinary rule's diagnosis where it has one
|
|
134
|
+
* (empty, traversal, separator), so those refusals cannot drift apart from their
|
|
135
|
+
* originals; a name the ordinary rule accepts is still refused here, as an
|
|
136
|
+
* invalid-charset name, because only the literal is allowed. The refusal reports the
|
|
137
|
+
* name AS WRITTEN.
|
|
138
|
+
*/
|
|
139
|
+
export function validateSkillOutputName(name) {
|
|
140
|
+
if (name === WORKFLOW_SKILL_ENTRY_NAME)
|
|
141
|
+
return Ok(name);
|
|
142
|
+
const ordinary = validateOutputName(name);
|
|
143
|
+
return Err(ordinary.ok ? { kind: 'invalid-charset', name } : ordinary.error);
|
|
144
|
+
}
|
|
104
145
|
/**
|
|
105
146
|
* The only directories the MDS build may write into, each tagged with the host
|
|
106
147
|
* variant it selects.
|
|
107
148
|
*
|
|
108
149
|
* `dist/commands` holds compiled slash commands; `dist/agents` holds agents
|
|
109
150
|
* compiled from generator hosts; `dist/skills/git/references` holds the generated
|
|
110
|
-
* `devflow:git` skill references
|
|
151
|
+
* `devflow:git` skill references; `dist/workflows` holds the compiled workflow skill
|
|
152
|
+
* folders. Adding an entry here is the single place a new
|
|
111
153
|
* build destination becomes legal — and `satisfies` forces that entry to declare
|
|
112
154
|
* a HostVariant, so no destination can arrive without saying how it is treated.
|
|
113
155
|
*/
|
|
@@ -159,10 +201,46 @@ export const SKILL_REFS_SKILL_NAME = 'git';
|
|
|
159
201
|
* no reference module is planned — the same reason AGENTS_OUTPUT_DIR is exported.
|
|
160
202
|
*/
|
|
161
203
|
export const SKILL_REFS_OUTPUT_DIR = `dist/skills/${SKILL_REFS_SKILL_NAME}/references`;
|
|
204
|
+
/**
|
|
205
|
+
* Repo-relative root of the compliance-off references: the one source root a
|
|
206
|
+
* compliance-off install overlays from.
|
|
207
|
+
*
|
|
208
|
+
* It sits inside the compliance-off prompt tree (COMPLIANCE_OFF_OUTPUT_DIR), beside
|
|
209
|
+
* `commands/` and `agents/`, and mirrors SKILL_REFS_OUTPUT_DIR below that tree. It is
|
|
210
|
+
* deliberately NOT an entry of the allowlist below: the build writes it as a second
|
|
211
|
+
* output of a reference module, so no host can declare it as its `output-dir:`.
|
|
212
|
+
*
|
|
213
|
+
* The tree is complete, not a patch over the compliance-on one: it holds every file of
|
|
214
|
+
* `installedReferenceManifest({ compliance: false })`, a byte copy of the compliance-on
|
|
215
|
+
* file where the module has no compliance arm there. An installer that reads this root
|
|
216
|
+
* therefore never has to fall through to SKILL_REFS_OUTPUT_DIR (AC-50).
|
|
217
|
+
*/
|
|
218
|
+
export const COMPLIANCE_OFF_SKILL_REFS_OUTPUT_DIR = `${COMPLIANCE_OFF_OUTPUT_DIR}/skills/${SKILL_REFS_SKILL_NAME}/references`;
|
|
219
|
+
/**
|
|
220
|
+
* The kind folder the workflow skill folders sit in: `dist/workflows`, and
|
|
221
|
+
* `<variant tree>/workflows` for every other variant key, as `commands` and `agents`
|
|
222
|
+
* are the kind folders of the prompts.
|
|
223
|
+
*/
|
|
224
|
+
export const WORKFLOW_FOLDERS_KIND = 'workflows';
|
|
225
|
+
/**
|
|
226
|
+
* Repo-relative destination for `workflow-skill` hosts: the root that holds one folder
|
|
227
|
+
* per registered workflow skill, `dist/workflows/<folder>/…`.
|
|
228
|
+
*
|
|
229
|
+
* D-WORKFLOW-FOLDER-BUILD: a host declares this ROOT, never its folder. The folder and
|
|
230
|
+
* the file inside it come from the registry WORKFLOW_FOLDERS, for the reason a
|
|
231
|
+
* reference module's operations do: the names are a typed roster, not frontmatter.
|
|
232
|
+
* The off variants of a folder sit under the trees of the other variant keys
|
|
233
|
+
* (workflowFoldersRootRelPath), none of which is a destination a host may declare.
|
|
234
|
+
*
|
|
235
|
+
* Exported because the build's orphan prune names this tree even when no folder is
|
|
236
|
+
* registered, which is exactly the case where every folder in it is an orphan.
|
|
237
|
+
*/
|
|
238
|
+
export const WORKFLOW_FOLDERS_OUTPUT_DIR = `dist/${WORKFLOW_FOLDERS_KIND}`;
|
|
162
239
|
const ALLOWED_OUTPUT_DIRS = [
|
|
163
240
|
{ dir: COMMANDS_OUTPUT_DIR, variant: 'commands' },
|
|
164
241
|
{ dir: AGENTS_OUTPUT_DIR, variant: 'agents' },
|
|
165
242
|
{ dir: SKILL_REFS_OUTPUT_DIR, variant: 'skill-refs' },
|
|
243
|
+
{ dir: WORKFLOW_FOLDERS_OUTPUT_DIR, variant: 'workflow-skill' },
|
|
166
244
|
];
|
|
167
245
|
/**
|
|
168
246
|
* The allowlisted directory names, in declaration order, for error rendering.
|
|
@@ -325,6 +403,33 @@ export const PR_HOST_OPS = [
|
|
|
325
403
|
* GitHub.
|
|
326
404
|
*/
|
|
327
405
|
export const PR_HOST_DESTINATION_ROOT = 'pr';
|
|
406
|
+
/**
|
|
407
|
+
* The eight Git operations that post to GitHub or to a tracker: the one roster of
|
|
408
|
+
* them. Posting steps are a compile-time variant keyed on the machine compliance
|
|
409
|
+
* switch alone, and this list says which reference files that variant drops.
|
|
410
|
+
*
|
|
411
|
+
* A compliance-off install carries no reference file of any of them: four under
|
|
412
|
+
* `pr/` and, for each of the three providers, four under `tracker/{provider}/`, which
|
|
413
|
+
* is 16 files. `check-merge-readiness` is deliberately absent: it reads state and
|
|
414
|
+
* posts nothing. The names are checked against the registry twice, once at compile
|
|
415
|
+
* time here (each is a PR-host or a tracker operation) and once at run time by
|
|
416
|
+
* {@link checkPostingOps}, because the manifest that omits them must fail closed: a
|
|
417
|
+
* name that matched no file would leave a posting reference in a compliance-off
|
|
418
|
+
* install without a signal.
|
|
419
|
+
*
|
|
420
|
+
* Tests take this list from here and never retype it
|
|
421
|
+
* (POSTING_OPERATIONS in tests/fixtures/mds-manifest.ts).
|
|
422
|
+
*/
|
|
423
|
+
export const POSTING_OPS = [
|
|
424
|
+
'post-review-summary',
|
|
425
|
+
'resolve-review-threads',
|
|
426
|
+
'post-resolution-summary',
|
|
427
|
+
'update-pr-evidence',
|
|
428
|
+
'manage-debt',
|
|
429
|
+
'post-wave-report',
|
|
430
|
+
'backlink-shipped-issues',
|
|
431
|
+
'associate-release',
|
|
432
|
+
];
|
|
328
433
|
/**
|
|
329
434
|
* The cross-cutting `devflow:git` reference documents — provider-independent, so
|
|
330
435
|
* they land at the root of the references directory rather than under
|
|
@@ -360,6 +465,13 @@ export const GIT_CROSS_CUTTING_DOCS = [
|
|
|
360
465
|
'publication-gate',
|
|
361
466
|
'trust-rule',
|
|
362
467
|
];
|
|
468
|
+
/**
|
|
469
|
+
* The cross-cutting documents a compliance-off install omits besides the references
|
|
470
|
+
* of {@link POSTING_OPS}: `publication-gate` holds the D10 step order and is named
|
|
471
|
+
* from the two summary operations only, so once those are gone nothing loads it.
|
|
472
|
+
* A subset of {@link GIT_CROSS_CUTTING_DOCS}, checked at compile time.
|
|
473
|
+
*/
|
|
474
|
+
export const COMPLIANCE_OFF_OMITTED_DOCS = ['publication-gate'];
|
|
363
475
|
/**
|
|
364
476
|
* The Git operations whose one operation reference is a cross-cutting document
|
|
365
477
|
* rather than a per-op file in `pr/` or `tracker/{provider}/` (D-OP-ONE-REF).
|
|
@@ -632,6 +744,10 @@ export function deferredReferenceModuleSources(modules = VARIANT_MODULES) {
|
|
|
632
744
|
* VariantModuleKind for why a count proves nothing about a named document set.
|
|
633
745
|
*/
|
|
634
746
|
export const MIN_VARIANT_PAIRS = 8;
|
|
747
|
+
/** POSIX path, relative to SKILL_REFS_OUTPUT_DIR, of the file a module writes for one of its names. */
|
|
748
|
+
function referenceRelPath(mod, op) {
|
|
749
|
+
return mod.subdir === '' ? `${op}.md` : `${mod.subdir}/${op}.md`;
|
|
750
|
+
}
|
|
635
751
|
/**
|
|
636
752
|
* Expand reference modules into the flat `(module, op)` pair list the build
|
|
637
753
|
* writes.
|
|
@@ -693,7 +809,7 @@ export function expandVariants(modules = resolveVariantModules()) {
|
|
|
693
809
|
if (!nameResult.ok) {
|
|
694
810
|
return Err({ kind: 'invalid-op-name', module: mod.source, op, cause: nameResult.error });
|
|
695
811
|
}
|
|
696
|
-
const relPath = mod
|
|
812
|
+
const relPath = referenceRelPath(mod, op);
|
|
697
813
|
const claimants = claimedBy.get(relPath);
|
|
698
814
|
if (claimants === undefined) {
|
|
699
815
|
claimedBy.set(relPath, [mod.source]);
|
|
@@ -755,12 +871,29 @@ export function generatedReferenceManifest() {
|
|
|
755
871
|
* its sibling does (design review M3): the registry is a compile-time constant,
|
|
756
872
|
* so a refusal is a programming error rather than an install-time degradation.
|
|
757
873
|
*
|
|
874
|
+
* `compliance: false` is the install of a machine with compliance off: the same
|
|
875
|
+
* manifest without the references of the posting operations ({@link POSTING_OPS},
|
|
876
|
+
* 16 files) and without the publication gate document, which only those operations
|
|
877
|
+
* load. Any other value, and an absent option, is today's manifest. The omission
|
|
878
|
+
* fails closed: a posting name the registry does not carry asserts, because a name
|
|
879
|
+
* that matched no file would leave a posting reference in a compliance-off install.
|
|
880
|
+
*
|
|
758
881
|
* @param opts.modules - Registry to expand (defaults to the shipped one, with the
|
|
759
882
|
* gated contract module resolved). Injectable so the refusal arm is provable
|
|
760
883
|
* without editing the registry.
|
|
884
|
+
* @param opts.compliance - `false` for the compliance-off install; the default is on.
|
|
761
885
|
*/
|
|
762
886
|
export function installedReferenceManifest(opts = {}) {
|
|
763
|
-
|
|
887
|
+
const modules = opts.modules ?? resolveVariantModules();
|
|
888
|
+
const manifest = expandedManifest(modules);
|
|
889
|
+
if (opts.compliance !== false)
|
|
890
|
+
return manifest;
|
|
891
|
+
const omitted = complianceOffOmittedRelPaths(modules);
|
|
892
|
+
if (!omitted.ok) {
|
|
893
|
+
throw new Error(`${describePostingOpsError(omitted.error)}. POSTING_OPS and VARIANT_MODULES in ` +
|
|
894
|
+
`src/core/mds-variants.ts must agree.`);
|
|
895
|
+
}
|
|
896
|
+
return manifest.filter(rel => !omitted.value.has(rel));
|
|
764
897
|
}
|
|
765
898
|
/** Every relPath a registry expands to, or the assertion both manifests share. */
|
|
766
899
|
function expandedManifest(modules) {
|
|
@@ -771,6 +904,94 @@ function expandedManifest(modules) {
|
|
|
771
904
|
}
|
|
772
905
|
return expanded.value.map(pair => pair.relPath);
|
|
773
906
|
}
|
|
907
|
+
/**
|
|
908
|
+
* Check a roster of posting operations against the registry: every name must be an
|
|
909
|
+
* operation of a fan-out module, and none may be listed twice.
|
|
910
|
+
*
|
|
911
|
+
* A named document is not an operation, so a document name is refused here: that
|
|
912
|
+
* keeps the posting roster from growing into a second list of documents to omit
|
|
913
|
+
* (those are {@link COMPLIANCE_OFF_OMITTED_DOCS}). The loop runs once over the roster
|
|
914
|
+
* given, so its bound is the roster's own length.
|
|
915
|
+
*
|
|
916
|
+
* @param ops - The roster to check (the shipped one is {@link POSTING_OPS}).
|
|
917
|
+
* @param modules - The registry to check it against.
|
|
918
|
+
*/
|
|
919
|
+
export function checkPostingOps(ops, modules) {
|
|
920
|
+
const registered = new Set(modules.filter(mod => mod.kind === 'fanout').flatMap(mod => mod.ops));
|
|
921
|
+
const seen = new Set();
|
|
922
|
+
for (const op of ops) {
|
|
923
|
+
if (seen.has(op))
|
|
924
|
+
return Err({ kind: 'duplicate-posting-op', op });
|
|
925
|
+
seen.add(op);
|
|
926
|
+
if (!registered.has(op))
|
|
927
|
+
return Err({ kind: 'unregistered-posting-op', op });
|
|
928
|
+
}
|
|
929
|
+
return Ok(ops);
|
|
930
|
+
}
|
|
931
|
+
/** Render a posting-roster refusal as one line, with a `never` default so a new kind cannot fall through. */
|
|
932
|
+
export function describePostingOpsError(error) {
|
|
933
|
+
switch (error.kind) {
|
|
934
|
+
case 'unregistered-posting-op':
|
|
935
|
+
return (`posting operation '${error.op}' is not an operation of any fan-out reference module ` +
|
|
936
|
+
`in VARIANT_MODULES`);
|
|
937
|
+
case 'duplicate-posting-op':
|
|
938
|
+
return `posting operation '${error.op}' is listed twice in the posting roster`;
|
|
939
|
+
default: {
|
|
940
|
+
const unhandled = error;
|
|
941
|
+
return `unhandled posting roster refusal ${JSON.stringify(unhandled)}`;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Does a compliance-off install omit the reference this module writes for `op`?
|
|
947
|
+
*
|
|
948
|
+
* A fan-out module's file is omitted when `op` is a posting operation; a named
|
|
949
|
+
* document is omitted when it is in {@link COMPLIANCE_OFF_OMITTED_DOCS}; the contract
|
|
950
|
+
* document is never omitted. The `never` default makes a fourth module kind answer
|
|
951
|
+
* the question here rather than inherit one of these answers.
|
|
952
|
+
*/
|
|
953
|
+
function omittedWhenComplianceOff(kind, op, postingOps) {
|
|
954
|
+
switch (kind) {
|
|
955
|
+
case 'fanout':
|
|
956
|
+
return postingOps.includes(op);
|
|
957
|
+
case 'named':
|
|
958
|
+
return COMPLIANCE_OFF_OMITTED_DOCS.includes(op);
|
|
959
|
+
case 'contract':
|
|
960
|
+
return false;
|
|
961
|
+
default: {
|
|
962
|
+
// A kind added to VariantModuleKind stops compiling at the next line. If one
|
|
963
|
+
// reached run time anyway it fails closed: its reference is left out.
|
|
964
|
+
const unhandled = kind;
|
|
965
|
+
void unhandled;
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* The relative paths (under the references root) a compliance-off install omits, for
|
|
972
|
+
* a registry: every file of a posting operation, and the omitted documents.
|
|
973
|
+
*
|
|
974
|
+
* The one owner of the omission. The compliance-off manifest filters by it, and the
|
|
975
|
+
* build decides from it which references get a compliance-off copy, so the manifest an
|
|
976
|
+
* installer converges to and the tree the build writes cannot drift apart.
|
|
977
|
+
*
|
|
978
|
+
* @param modules - The registry to read.
|
|
979
|
+
* @param postingOps - The posting roster (defaults to {@link POSTING_OPS}); injectable
|
|
980
|
+
* so the refusal of a bogus roster is provable without editing the shipped one.
|
|
981
|
+
*/
|
|
982
|
+
export function complianceOffOmittedRelPaths(modules, postingOps = POSTING_OPS) {
|
|
983
|
+
const checked = checkPostingOps(postingOps, modules);
|
|
984
|
+
if (!checked.ok)
|
|
985
|
+
return Err(checked.error);
|
|
986
|
+
const omitted = new Set();
|
|
987
|
+
for (const mod of modules) {
|
|
988
|
+
for (const op of mod.ops) {
|
|
989
|
+
if (omittedWhenComplianceOff(mod.kind, op, postingOps))
|
|
990
|
+
omitted.add(referenceRelPath(mod, op));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return Ok(omitted);
|
|
994
|
+
}
|
|
774
995
|
// ---------------------------------------------------------------------------
|
|
775
996
|
// Section splitting — which slice of a module's compiled body belongs to which op
|
|
776
997
|
// ---------------------------------------------------------------------------
|
|
@@ -874,4 +1095,480 @@ export function splitVariantSections(body, entries) {
|
|
|
874
1095
|
}
|
|
875
1096
|
return Ok(out);
|
|
876
1097
|
}
|
|
1098
|
+
/** A variant body as a reference file holds it: trimmed, one trailing newline, or empty when it has no text. */
|
|
1099
|
+
function settleSectionBody(body) {
|
|
1100
|
+
const trimmed = body.trim();
|
|
1101
|
+
return trimmed === '' ? '' : `${trimmed}\n`;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Split one operation's section on its compliance arms.
|
|
1105
|
+
*
|
|
1106
|
+
* The caller has already split the module into operation sections
|
|
1107
|
+
* (splitVariantSections), so an arm that runs across two sections is an unclosed arm
|
|
1108
|
+
* in the first and an unopened end in the second, and each is refused here with the
|
|
1109
|
+
* operation it is in. Each variant is settled the way splitVariantSections settles a
|
|
1110
|
+
* section, so a section with no arm gives both variants its own bytes.
|
|
1111
|
+
*
|
|
1112
|
+
* Curation is outside this split: the splitter keeps the curation-on text in both
|
|
1113
|
+
* bodies, and the build refuses a curation marker in a reference module before it
|
|
1114
|
+
* gets here.
|
|
1115
|
+
*
|
|
1116
|
+
* Total and pure: a refusal is a Result.
|
|
1117
|
+
*
|
|
1118
|
+
* @param op - The operation, for the refusal.
|
|
1119
|
+
* @param content - The settled section from splitVariantSections.
|
|
1120
|
+
* @param opts.complianceOffWritten - Whether the build writes a compliance-off file
|
|
1121
|
+
* for this operation. A variant that is written must have text; an operation the
|
|
1122
|
+
* compliance-off install omits writes none, and its compliance-off body may be empty.
|
|
1123
|
+
*/
|
|
1124
|
+
export function splitSectionArms(op, content, opts) {
|
|
1125
|
+
const split = splitVariants(content);
|
|
1126
|
+
if (!split.ok)
|
|
1127
|
+
return Err({ kind: 'arm-split', op, error: split.error });
|
|
1128
|
+
const on = settleSectionBody(split.value.bodies.on);
|
|
1129
|
+
const complianceOff = settleSectionBody(split.value.bodies['compliance-off']);
|
|
1130
|
+
if (on === '')
|
|
1131
|
+
return Err({ kind: 'empty-variant', op, variant: 'on' });
|
|
1132
|
+
if (opts.complianceOffWritten && complianceOff === '') {
|
|
1133
|
+
return Err({ kind: 'empty-variant', op, variant: 'compliance-off' });
|
|
1134
|
+
}
|
|
1135
|
+
return Ok({ on, complianceOff });
|
|
1136
|
+
}
|
|
1137
|
+
// ---------------------------------------------------------------------------
|
|
1138
|
+
// Workflow skill folders: the registry, the files of each variant key, the build id
|
|
1139
|
+
// ---------------------------------------------------------------------------
|
|
1140
|
+
/** The compiled name of a folder's core host: the skill's entry file. */
|
|
1141
|
+
export const WORKFLOW_SKILL_FILE = `${WORKFLOW_SKILL_ENTRY_NAME}.md`;
|
|
1142
|
+
/**
|
|
1143
|
+
* The subfolder of reference files that sit outside the build id: they are read on
|
|
1144
|
+
* demand by link, not as part of the skill's own text, and they ship byte-equal to
|
|
1145
|
+
* their compiled body.
|
|
1146
|
+
*/
|
|
1147
|
+
export const WORKFLOW_REFERENCES_DIR = 'references';
|
|
1148
|
+
/**
|
|
1149
|
+
* The workflow skill folders this build compiles.
|
|
1150
|
+
*
|
|
1151
|
+
* D-WORKFLOW-FOLDER-BUILD: the set is empty, and the build layer ships with it empty.
|
|
1152
|
+
* No host in src/assets becomes a folder until a folder is registered here, so every
|
|
1153
|
+
* file the build wrote before is byte-identical. A host that declares the workflow
|
|
1154
|
+
* root without a row here is refused, which is what keeps a folder from appearing by
|
|
1155
|
+
* accident. The registry is a typed roster rather than frontmatter for the reason the
|
|
1156
|
+
* git reference modules' roster is: the folder and file names are decided in one
|
|
1157
|
+
* reviewed place.
|
|
1158
|
+
*/
|
|
1159
|
+
export const WORKFLOW_FOLDERS = [];
|
|
1160
|
+
/** Every host of a folder, the core first, then the references in registry order. */
|
|
1161
|
+
export function workflowFolderHosts(folder) {
|
|
1162
|
+
return [
|
|
1163
|
+
{ source: folder.core, file: WORKFLOW_SKILL_FILE, role: 'core', omittedWhenComplianceOff: false },
|
|
1164
|
+
...folder.references.map((ref) => ({
|
|
1165
|
+
source: ref.source,
|
|
1166
|
+
file: ref.file,
|
|
1167
|
+
role: 'reference',
|
|
1168
|
+
omittedWhenComplianceOff: ref.omittedWhenComplianceOff === true,
|
|
1169
|
+
})),
|
|
1170
|
+
];
|
|
1171
|
+
}
|
|
1172
|
+
/** The host a repo-relative source path is registered as, and its folder; null when no folder lists it. */
|
|
1173
|
+
export function findWorkflowFolderHost(folders, source) {
|
|
1174
|
+
for (const folder of folders) {
|
|
1175
|
+
const host = workflowFolderHosts(folder).find(h => h.source === source);
|
|
1176
|
+
if (host !== undefined)
|
|
1177
|
+
return { folder, host };
|
|
1178
|
+
}
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* A repo-relative POSIX `.mds` path that stays inside the repo: no backslash, no
|
|
1183
|
+
* leading slash, no dot segment, spelled canonically.
|
|
1184
|
+
*/
|
|
1185
|
+
function isRepoRelativeMdsSource(source) {
|
|
1186
|
+
return source.endsWith('.mds')
|
|
1187
|
+
&& !source.includes('\\')
|
|
1188
|
+
&& !path.posix.isAbsolute(source)
|
|
1189
|
+
&& !source.startsWith('../')
|
|
1190
|
+
&& path.posix.normalize(source) === source;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Validate the compiled name of a reference file: `<name>.md` or `references/<name>.md`,
|
|
1194
|
+
* with the ordinary output-name rule on the stem. `SKILL` is refused by that rule (it is
|
|
1195
|
+
* uppercase), so the entry file belongs to the core host alone.
|
|
1196
|
+
*/
|
|
1197
|
+
function validateWorkflowReferenceFile(file) {
|
|
1198
|
+
const prefix = `${WORKFLOW_REFERENCES_DIR}/`;
|
|
1199
|
+
const bare = file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
1200
|
+
if (!bare.endsWith('.md'))
|
|
1201
|
+
return Err({ kind: 'invalid-charset', name: file });
|
|
1202
|
+
const stem = validateOutputName(bare.slice(0, -'.md'.length));
|
|
1203
|
+
if (stem.ok)
|
|
1204
|
+
return Ok(file);
|
|
1205
|
+
return Err(stem.error.kind === 'empty' ? { kind: 'empty' } : { ...stem.error, name: file });
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Check a registry: every folder and file name is safe to join onto a build
|
|
1209
|
+
* destination, every source is a repo-relative `.mds` path, and no folder, source or
|
|
1210
|
+
* file is listed twice.
|
|
1211
|
+
*
|
|
1212
|
+
* Total and pure. Each loop is bounded by the registry it is given.
|
|
1213
|
+
*/
|
|
1214
|
+
export function checkWorkflowFolders(folders) {
|
|
1215
|
+
const folderNames = new Set();
|
|
1216
|
+
const sources = new Set();
|
|
1217
|
+
for (const folder of folders) {
|
|
1218
|
+
const named = validateOutputName(folder.name);
|
|
1219
|
+
if (!named.ok)
|
|
1220
|
+
return Err({ kind: 'bad-folder-name', folder: folder.name, error: named.error });
|
|
1221
|
+
if (folderNames.has(folder.name))
|
|
1222
|
+
return Err({ kind: 'duplicate-folder', folder: folder.name });
|
|
1223
|
+
folderNames.add(folder.name);
|
|
1224
|
+
const files = new Set();
|
|
1225
|
+
for (const host of workflowFolderHosts(folder)) {
|
|
1226
|
+
if (!isRepoRelativeMdsSource(host.source)) {
|
|
1227
|
+
return Err({ kind: 'bad-source', folder: folder.name, source: host.source });
|
|
1228
|
+
}
|
|
1229
|
+
if (sources.has(host.source))
|
|
1230
|
+
return Err({ kind: 'duplicate-source', source: host.source });
|
|
1231
|
+
sources.add(host.source);
|
|
1232
|
+
if (host.role === 'reference') {
|
|
1233
|
+
const file = validateWorkflowReferenceFile(host.file);
|
|
1234
|
+
if (!file.ok)
|
|
1235
|
+
return Err({ kind: 'bad-file', folder: folder.name, file: host.file, error: file.error });
|
|
1236
|
+
}
|
|
1237
|
+
if (files.has(host.file))
|
|
1238
|
+
return Err({ kind: 'duplicate-file', folder: folder.name, file: host.file });
|
|
1239
|
+
files.add(host.file);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return Ok(folders);
|
|
1243
|
+
}
|
|
1244
|
+
function isRecord(value) {
|
|
1245
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1246
|
+
}
|
|
1247
|
+
function parseWorkflowReference(value, at) {
|
|
1248
|
+
if (!isRecord(value))
|
|
1249
|
+
return Err({ kind: 'malformed', at });
|
|
1250
|
+
const { source, file, omittedWhenComplianceOff } = value;
|
|
1251
|
+
if (typeof source !== 'string')
|
|
1252
|
+
return Err({ kind: 'malformed', at: `${at}.source` });
|
|
1253
|
+
if (typeof file !== 'string')
|
|
1254
|
+
return Err({ kind: 'malformed', at: `${at}.file` });
|
|
1255
|
+
if (omittedWhenComplianceOff !== undefined && typeof omittedWhenComplianceOff !== 'boolean') {
|
|
1256
|
+
return Err({ kind: 'malformed', at: `${at}.omittedWhenComplianceOff` });
|
|
1257
|
+
}
|
|
1258
|
+
return Ok(omittedWhenComplianceOff === undefined ? { source, file } : { source, file, omittedWhenComplianceOff });
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Parse a registry from untrusted data (JSON that crossed a process boundary) and
|
|
1262
|
+
* check it: the shape first, then {@link checkWorkflowFolders}. Pure; a refusal
|
|
1263
|
+
* names the path of the first malformed field.
|
|
1264
|
+
*/
|
|
1265
|
+
export function parseWorkflowFolders(value) {
|
|
1266
|
+
if (!Array.isArray(value))
|
|
1267
|
+
return Err({ kind: 'malformed', at: '$' });
|
|
1268
|
+
const folders = [];
|
|
1269
|
+
for (const [index, entry] of value.entries()) {
|
|
1270
|
+
const at = `$[${index}]`;
|
|
1271
|
+
if (!isRecord(entry))
|
|
1272
|
+
return Err({ kind: 'malformed', at });
|
|
1273
|
+
const { name, core, references } = entry;
|
|
1274
|
+
if (typeof name !== 'string')
|
|
1275
|
+
return Err({ kind: 'malformed', at: `${at}.name` });
|
|
1276
|
+
if (typeof core !== 'string')
|
|
1277
|
+
return Err({ kind: 'malformed', at: `${at}.core` });
|
|
1278
|
+
if (!Array.isArray(references))
|
|
1279
|
+
return Err({ kind: 'malformed', at: `${at}.references` });
|
|
1280
|
+
const parsed = [];
|
|
1281
|
+
for (const [refIndex, ref] of references.entries()) {
|
|
1282
|
+
const result = parseWorkflowReference(ref, `${at}.references[${refIndex}]`);
|
|
1283
|
+
if (!result.ok)
|
|
1284
|
+
return Err(result.error);
|
|
1285
|
+
parsed.push(result.value);
|
|
1286
|
+
}
|
|
1287
|
+
folders.push({ name, core, references: parsed });
|
|
1288
|
+
}
|
|
1289
|
+
return checkWorkflowFolders(folders);
|
|
1290
|
+
}
|
|
1291
|
+
/** Render a registry refusal as one line a person can act on. */
|
|
1292
|
+
export function describeWorkflowFolderError(error) {
|
|
1293
|
+
switch (error.kind) {
|
|
1294
|
+
case 'malformed':
|
|
1295
|
+
return `the workflow folder registry is malformed at ${error.at}`;
|
|
1296
|
+
case 'bad-folder-name':
|
|
1297
|
+
return `workflow folder name '${error.folder}' is refused (${error.error.kind})`;
|
|
1298
|
+
case 'duplicate-folder':
|
|
1299
|
+
return `workflow folder '${error.folder}' is registered twice`;
|
|
1300
|
+
case 'bad-source':
|
|
1301
|
+
return `workflow folder '${error.folder}' lists '${error.source}', which is not a repo-relative .mds path`;
|
|
1302
|
+
case 'duplicate-source':
|
|
1303
|
+
return `the source '${error.source}' is listed twice in the workflow folder registry`;
|
|
1304
|
+
case 'bad-file':
|
|
1305
|
+
return `workflow folder '${error.folder}' names the file '${error.file}', which is refused (${error.error.kind})`;
|
|
1306
|
+
case 'duplicate-file':
|
|
1307
|
+
return `workflow folder '${error.folder}' names the file '${error.file}' twice`;
|
|
1308
|
+
default: {
|
|
1309
|
+
const unhandled = error;
|
|
1310
|
+
return `unhandled workflow folder refusal ${JSON.stringify(unhandled)}`;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
/** A folder is complete for every key, so the key a machine reads is the one for its switches alone. */
|
|
1315
|
+
const FOLDER_ARMS = { curation: true, compliance: true };
|
|
1316
|
+
/**
|
|
1317
|
+
* The variant key of the folder a machine with these switches reads.
|
|
1318
|
+
*
|
|
1319
|
+
* Unlike a prompt, a folder is complete under every key (a byte copy of the on text
|
|
1320
|
+
* where no arm applies), so the key follows the switches alone and never depends on
|
|
1321
|
+
* which arms a file carries. The mapping is machineVariantKey's, with every arm assumed
|
|
1322
|
+
* present, so the key table has one owner.
|
|
1323
|
+
*/
|
|
1324
|
+
export function workflowFolderKey(switches) {
|
|
1325
|
+
return machineVariantKey(switches, FOLDER_ARMS);
|
|
1326
|
+
}
|
|
1327
|
+
/** The keys whose folder omits the compliance-gated files: the ones that turn compliance off. */
|
|
1328
|
+
const COMPLIANCE_OFF_KEYS = new Set([true, false].map(curation => workflowFolderKey({ curation, compliance: false })));
|
|
1329
|
+
/**
|
|
1330
|
+
* The files one variant key's folder holds, in registry order, the core first.
|
|
1331
|
+
*
|
|
1332
|
+
* The one owner of "which files does this key hold": the build plans a file's
|
|
1333
|
+
* destination as absent exactly when it is not listed here, and checks each key's
|
|
1334
|
+
* folder against this list. A key that lacks a file of its own list fails the build;
|
|
1335
|
+
* a file the key's list leaves out is never required.
|
|
1336
|
+
*/
|
|
1337
|
+
export function workflowFolderFiles(folder, key) {
|
|
1338
|
+
const complianceOff = COMPLIANCE_OFF_KEYS.has(key);
|
|
1339
|
+
return workflowFolderHosts(folder)
|
|
1340
|
+
.filter(host => !(complianceOff && host.omittedWhenComplianceOff))
|
|
1341
|
+
.map(host => host.file);
|
|
1342
|
+
}
|
|
1343
|
+
/** Repo-relative POSIX root of the folders of one variant key: `dist/workflows`, or `<variant tree>/workflows`. */
|
|
1344
|
+
export function workflowFoldersRootRelPath(key) {
|
|
1345
|
+
return key === 'on' ? WORKFLOW_FOLDERS_OUTPUT_DIR : `${OFF_OUTPUT_DIRS[key]}/${WORKFLOW_FOLDERS_KIND}`;
|
|
1346
|
+
}
|
|
1347
|
+
/** Repo-relative POSIX path of one file of one folder under one variant key. */
|
|
1348
|
+
export function workflowFolderRelPath(key, folder, file) {
|
|
1349
|
+
return `${workflowFoldersRootRelPath(key)}/${folder}/${file}`;
|
|
1350
|
+
}
|
|
1351
|
+
/** Whether a file of a folder carries the build id. The reference files are outside it. */
|
|
1352
|
+
export function isStampedWorkflowFile(file) {
|
|
1353
|
+
return !file.startsWith(`${WORKFLOW_REFERENCES_DIR}/`);
|
|
1354
|
+
}
|
|
1355
|
+
/** Length of the build id, in hex digits. */
|
|
1356
|
+
export const BUILD_ID_LENGTH = 12;
|
|
1357
|
+
/** The stamp as a heading carries it: a suffix on the first heading line. */
|
|
1358
|
+
const BUILD_STAMP_SUFFIX_RE = new RegExp(` \\[build ([0-9a-f]{${BUILD_ID_LENGTH}})\\]$`);
|
|
1359
|
+
function byCodeUnit(a, b) {
|
|
1360
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* The build id of one folder under one variant key: the first {@link BUILD_ID_LENGTH}
|
|
1364
|
+
* hex digits of the SHA-256 over the folder's compiled bodies as they stand BEFORE the
|
|
1365
|
+
* stamp, sorted by file name.
|
|
1366
|
+
*
|
|
1367
|
+
* D-BUILD-ID: one id per folder and key, and nothing else goes into it: no timestamp,
|
|
1368
|
+
* no per-file hash. The same sources therefore give the same id on every machine and
|
|
1369
|
+
* every run, and any change to any file of the folder moves it. The digest covers
|
|
1370
|
+
* every file of the key's folder, the reference files included, so a changed reference
|
|
1371
|
+
* moves the id of its folder; only the STAMP skips them (isStampedWorkflowFile), which
|
|
1372
|
+
* keeps those files byte-equal to their compiled bodies.
|
|
1373
|
+
*
|
|
1374
|
+
* The id can be recomputed from a stamped folder. The stamp drops the blanks that end
|
|
1375
|
+
* the first heading line, so a stamped file enters the digest in that same form
|
|
1376
|
+
* ({@link stampReadyBody}) and {@link unstampBuildId} gives back the exact text that was
|
|
1377
|
+
* hashed. A body whose heading ends in blanks and one whose heading does not therefore
|
|
1378
|
+
* share an id, as they share their stamped text.
|
|
1379
|
+
*
|
|
1380
|
+
* Each file enters the digest as its name, its length in bytes, then its body, each
|
|
1381
|
+
* on a line of its own. The length makes the framing injective: two different sets of
|
|
1382
|
+
* files cannot produce one byte stream by moving text across a file boundary.
|
|
1383
|
+
*/
|
|
1384
|
+
export function workflowBuildId(files) {
|
|
1385
|
+
const hash = createHash('sha256');
|
|
1386
|
+
for (const { file, body } of [...files].sort((a, b) => byCodeUnit(a.file, b.file))) {
|
|
1387
|
+
const hashed = isStampedWorkflowFile(file) ? stampReadyBody(body) : body;
|
|
1388
|
+
hash.update(`${file}\n${Buffer.byteLength(hashed, 'utf8')}\n`);
|
|
1389
|
+
hash.update(hashed, 'utf8');
|
|
1390
|
+
hash.update('\n');
|
|
1391
|
+
}
|
|
1392
|
+
return hash.digest('hex').slice(0, BUILD_ID_LENGTH);
|
|
1393
|
+
}
|
|
1394
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
1395
|
+
const HEADING_RE = /^#{1,6}[ \t]+\S/;
|
|
1396
|
+
/**
|
|
1397
|
+
* Find the first ATX heading line, skipping a leading frontmatter block and every
|
|
1398
|
+
* fenced code block, so a `# comment` in either is never mistaken for it. One pass over
|
|
1399
|
+
* the lines, so the bound is the text's own length.
|
|
1400
|
+
*/
|
|
1401
|
+
function locateFirstHeading(text) {
|
|
1402
|
+
const lines = text.split('\n');
|
|
1403
|
+
let inFrontmatter = lines[0]?.replace(/\r$/, '') === '---';
|
|
1404
|
+
let fence = null;
|
|
1405
|
+
let offset = 0;
|
|
1406
|
+
for (const [index, raw] of lines.entries()) {
|
|
1407
|
+
const line = raw.replace(/\r$/, '');
|
|
1408
|
+
const start = offset;
|
|
1409
|
+
offset += raw.length + 1;
|
|
1410
|
+
if (inFrontmatter) {
|
|
1411
|
+
if (index > 0 && line === '---')
|
|
1412
|
+
inFrontmatter = false;
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const opened = FENCE_RE.exec(line);
|
|
1416
|
+
if (opened !== null) {
|
|
1417
|
+
const marker = opened[1];
|
|
1418
|
+
if (fence === null)
|
|
1419
|
+
fence = marker;
|
|
1420
|
+
else if (marker[0] === fence[0] && marker.length >= fence.length)
|
|
1421
|
+
fence = null;
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
if (fence === null && HEADING_RE.test(line))
|
|
1425
|
+
return { start, end: start + line.length };
|
|
1426
|
+
}
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
/** The offset where the text of a heading line ends: its end, less the blanks that close it. */
|
|
1430
|
+
function headingTextEnd(text, heading) {
|
|
1431
|
+
return heading.start + text.slice(heading.start, heading.end).replace(/[ \t]+$/, '').length;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* A compiled body in the form the stamp leaves it, before the suffix: the blanks that
|
|
1435
|
+
* end the first heading line dropped. A body with no heading is returned as it is.
|
|
1436
|
+
* {@link stampBuildId} writes the suffix after this text and {@link unstampBuildId}
|
|
1437
|
+
* returns this text, which is what lets the build id be recomputed from a stamped file.
|
|
1438
|
+
*/
|
|
1439
|
+
function stampReadyBody(body) {
|
|
1440
|
+
const heading = locateFirstHeading(body);
|
|
1441
|
+
if (heading === null)
|
|
1442
|
+
return body;
|
|
1443
|
+
return `${body.slice(0, headingTextEnd(body, heading))}${body.slice(heading.end)}`;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Stamp a build id into the first heading line of a compiled body, as the suffix
|
|
1447
|
+
* ` [build <id>]`.
|
|
1448
|
+
*
|
|
1449
|
+
* D-BUILD-ID: the heading is where a reader of the file sees the id first, and a
|
|
1450
|
+
* suffix leaves the heading text, and every anchor made from it, as written. A body
|
|
1451
|
+
* with no heading has nowhere to carry the id and is refused, and so is a heading that
|
|
1452
|
+
* already carries a stamp, which would otherwise hide a stale one behind the new one.
|
|
1453
|
+
* The blanks that end the heading line are dropped, so the suffix follows the text
|
|
1454
|
+
* directly; {@link unstampBuildId} undoes the stamp up to that one change.
|
|
1455
|
+
*/
|
|
1456
|
+
export function stampBuildId(body, id) {
|
|
1457
|
+
const heading = locateFirstHeading(body);
|
|
1458
|
+
if (heading === null)
|
|
1459
|
+
return Err({ kind: 'no-heading' });
|
|
1460
|
+
const existing = BUILD_STAMP_SUFFIX_RE.exec(body.slice(heading.start, heading.end));
|
|
1461
|
+
if (existing !== null)
|
|
1462
|
+
return Err({ kind: 'already-stamped', id: existing[1] });
|
|
1463
|
+
return Ok(`${body.slice(0, headingTextEnd(body, heading))} [build ${id}]${body.slice(heading.end)}`);
|
|
1464
|
+
}
|
|
1465
|
+
/** Read the build id out of the first heading line of a stamped file. */
|
|
1466
|
+
export function readBuildId(text) {
|
|
1467
|
+
const heading = locateFirstHeading(text);
|
|
1468
|
+
if (heading === null)
|
|
1469
|
+
return Err({ kind: 'no-heading' });
|
|
1470
|
+
const stamp = BUILD_STAMP_SUFFIX_RE.exec(text.slice(heading.start, heading.end));
|
|
1471
|
+
return stamp === null ? Err({ kind: 'no-stamp' }) : Ok(stamp[1]);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Take the stamp off a stamped file: the text without the suffix on its first heading
|
|
1475
|
+
* line, and the id the suffix carried.
|
|
1476
|
+
*
|
|
1477
|
+
* The inverse of {@link stampBuildId} on a body whose heading does not end in blanks
|
|
1478
|
+
* (a heading that does comes back without them, which is the form the build id is taken
|
|
1479
|
+
* over). Only the first heading is read, so a stamp-like text elsewhere in the file stays.
|
|
1480
|
+
*/
|
|
1481
|
+
export function unstampBuildId(text) {
|
|
1482
|
+
const heading = locateFirstHeading(text);
|
|
1483
|
+
if (heading === null)
|
|
1484
|
+
return Err({ kind: 'no-heading' });
|
|
1485
|
+
const stamp = BUILD_STAMP_SUFFIX_RE.exec(text.slice(heading.start, heading.end));
|
|
1486
|
+
if (stamp === null)
|
|
1487
|
+
return Err({ kind: 'no-stamp' });
|
|
1488
|
+
return Ok({ body: `${text.slice(0, heading.end - stamp[0].length)}${text.slice(heading.end)}`, id: stamp[1] });
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* The shared-id check of a folder: every stamped file of one folder and key carries the
|
|
1492
|
+
* same build id. Returns that id. It reads the stamps and nothing else; it does not
|
|
1493
|
+
* recompute the id from the files, and {@link verifyFolderBuildId} does.
|
|
1494
|
+
*
|
|
1495
|
+
* The reference files are outside it. A file with no heading or no stamp is refused by
|
|
1496
|
+
* name, and two ids are refused with every file's id listed, so a folder that mixes
|
|
1497
|
+
* files of two builds (an install that stopped half-way, a stale file copied in) stops
|
|
1498
|
+
* the check instead of passing as one skill.
|
|
1499
|
+
*/
|
|
1500
|
+
export function checkFolderBuildIds(files) {
|
|
1501
|
+
const ids = [];
|
|
1502
|
+
for (const { file, body } of files) {
|
|
1503
|
+
if (!isStampedWorkflowFile(file))
|
|
1504
|
+
continue;
|
|
1505
|
+
const read = readBuildId(body);
|
|
1506
|
+
if (!read.ok)
|
|
1507
|
+
return Err({ kind: 'unstamped', file, reason: read.error.kind });
|
|
1508
|
+
ids.push({ file, id: read.value });
|
|
1509
|
+
}
|
|
1510
|
+
if (ids.length === 0)
|
|
1511
|
+
return Err({ kind: 'no-stamped-file' });
|
|
1512
|
+
const first = ids[0].id;
|
|
1513
|
+
return ids.every(entry => entry.id === first) ? Ok(first) : Err({ kind: 'mixed-ids', ids });
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* The check a reader of a folder makes: the shared-id check ({@link checkFolderBuildIds}),
|
|
1517
|
+
* then the id recomputed over the unstamped stamped files and the reference bodies as
|
|
1518
|
+
* they stand, which must be the id the stamps carry. Returns that id.
|
|
1519
|
+
*
|
|
1520
|
+
* Every file of the key's folder must be in `files`, because the id covers them all. A
|
|
1521
|
+
* folder with one stamped file, or a reference file of another build, no longer passes
|
|
1522
|
+
* on the stamps alone: its files hash to another id. The check holds for a folder as
|
|
1523
|
+
* the build wrote it. It does not hold against someone who edits a file and the stamp
|
|
1524
|
+
* together, since the id is a plain digest and not a signature.
|
|
1525
|
+
*/
|
|
1526
|
+
export function verifyFolderBuildId(files) {
|
|
1527
|
+
const shared = checkFolderBuildIds(files);
|
|
1528
|
+
if (!shared.ok)
|
|
1529
|
+
return shared;
|
|
1530
|
+
const unstamped = [];
|
|
1531
|
+
for (const { file, body } of files) {
|
|
1532
|
+
if (!isStampedWorkflowFile(file)) {
|
|
1533
|
+
unstamped.push({ file, body });
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
const read = unstampBuildId(body);
|
|
1537
|
+
if (!read.ok)
|
|
1538
|
+
return Err({ kind: 'unstamped', file, reason: read.error.kind });
|
|
1539
|
+
unstamped.push({ file, body: read.value.body });
|
|
1540
|
+
}
|
|
1541
|
+
const recomputed = workflowBuildId(unstamped);
|
|
1542
|
+
return recomputed === shared.value ? Ok(shared.value) : Err({ kind: 'id-mismatch', stamped: shared.value, recomputed });
|
|
1543
|
+
}
|
|
1544
|
+
/** Render a stamp refusal as one line a person can act on. */
|
|
1545
|
+
export function describeBuildStampError(error) {
|
|
1546
|
+
switch (error.kind) {
|
|
1547
|
+
case 'no-heading':
|
|
1548
|
+
return 'it has no heading line to carry the build id; open the file with a heading';
|
|
1549
|
+
case 'already-stamped':
|
|
1550
|
+
return `its first heading already carries a build stamp (${error.id}); the build writes the stamp itself`;
|
|
1551
|
+
default: {
|
|
1552
|
+
const unhandled = error;
|
|
1553
|
+
return `unhandled build stamp refusal ${JSON.stringify(unhandled)}`;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
/** Render a build id check refusal as one line a person can act on. */
|
|
1558
|
+
export function describeBuildIdCheckError(error) {
|
|
1559
|
+
switch (error.kind) {
|
|
1560
|
+
case 'no-stamped-file':
|
|
1561
|
+
return 'the folder holds no file that carries a build id';
|
|
1562
|
+
case 'unstamped':
|
|
1563
|
+
return `${error.file} has no build id (${error.reason})`;
|
|
1564
|
+
case 'mixed-ids':
|
|
1565
|
+
return `the folder mixes build ids: ${error.ids.map(entry => `${entry.file} ${entry.id}`).join(', ')}`;
|
|
1566
|
+
case 'id-mismatch':
|
|
1567
|
+
return `the files of the folder hash to build id ${error.recomputed}, but the stamps carry ${error.stamped}; a file is from another build or was changed`;
|
|
1568
|
+
default: {
|
|
1569
|
+
const unhandled = error;
|
|
1570
|
+
return `unhandled build id refusal ${JSON.stringify(unhandled)}`;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
877
1574
|
//# sourceMappingURL=mds-variants.js.map
|