@plainconceptsplatform/workflows 1.10.1 → 1.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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog-installation.js +5 -0
  3. package/dist/catalog-listing.js +6 -1
  4. package/dist/index.js +27 -4
  5. package/dist/template-bundles.d.ts +21 -0
  6. package/dist/template-bundles.js +108 -0
  7. package/dist/workflow-catalog.d.ts +5 -1
  8. package/dist/workflow-catalog.js +8 -0
  9. package/loops/templates/ci/app-ci-dotnet-next.yml +776 -117
  10. package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +171 -0
  11. package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +178 -0
  12. package/loops/templates/quality-gates/.jscpd.json +36 -0
  13. package/loops/templates/quality-gates/.jscpd.json.md +7 -0
  14. package/loops/templates/quality-gates/.semgrep/platform-hygiene.yml +88 -0
  15. package/loops/templates/quality-gates/.zap/rules.tsv +26 -0
  16. package/loops/templates/quality-gates/scripts/check-complexity.mjs +86 -0
  17. package/loops/templates/quality-gates/scripts/check-coverage.mjs +89 -0
  18. package/loops/templates/quality-gates/scripts/check-duplication.mjs +67 -0
  19. package/loops/templates/quality-gates/scripts/check-mutation.mjs +67 -0
  20. package/loops/templates/quality-gates/scripts/dast-wait-stable.sh +53 -0
  21. package/loops/templates/quality-gates/scripts/engineering-excellence.mjs +188 -0
  22. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json +895 -0
  23. package/loops/templates/quality-gates/scripts/engineering-excellence.schema.json.md +5 -0
  24. package/loops/templates/quality-gates/scripts/lib/complexity-gate.mjs +80 -0
  25. package/loops/templates/quality-gates/scripts/lib/complexity-gate.test.mjs +105 -0
  26. package/loops/templates/quality-gates/scripts/lib/duplication-gate.mjs +50 -0
  27. package/loops/templates/quality-gates/scripts/lib/duplication-gate.test.mjs +47 -0
  28. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.mjs +674 -0
  29. package/loops/templates/quality-gates/scripts/lib/engineering-excellence.test.mjs +284 -0
  30. package/loops/templates/quality-gates/scripts/lib/hygiene-facet.test.mjs +179 -0
  31. package/loops/templates/quality-gates/scripts/lib/quality-config.mjs +65 -0
  32. package/loops/templates/quality-gates/scripts/lib/quality-config.test.mjs +29 -0
  33. package/loops/templates/quality-gates/scripts/lib/source-files.mjs +26 -0
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -81,7 +81,7 @@ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci
81
81
 
82
82
  ## Templates
83
83
 
84
- Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, and `opencode.ci.json`. CI templates are stack-specific copies, not a combined template. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
84
+ Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, `opencode.ci.json`, `platform-template` and `quality-gates`. CI templates are stack-specific copies, not a combined template. `quality-gates` and `platform-template` are bundles for the platform family: they stay package-managed and `update` refreshes them where they are installed; see `docs/family-templates.md` in the repository. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
85
85
 
86
86
  `agentics-error-report` is worth installing everywhere. Once a day it classifies how *this package's* workflows behaved in the repository and files what broke upstream, so the package gets fixed instead of every repository working around the same bug. It is deterministic no model runs, because a model asked to summarise a failure paraphrases whatever the log held and it sends only the names this package gives its own workflows, jobs and steps, a conclusion, a bucketed runner label, a count and a classification id. No log line, branch, title, path or issue number leaves the repository, the repository's own workflows are counted and never inspected, and a leak scanner withholds any report mentioning the repository or its owner and fails the run rather than filing it. Clear `UPSTREAM_NAME` in its `env:` to compute the report into the job summary and file nothing.
87
87
 
@@ -10,6 +10,7 @@ import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
10
10
  import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv, shippedVerifyDefaults } from "./stack-defaults.js";
11
11
  import { mergeRouter, mergeWorker } from "./worker-env.js";
12
12
  import { fetchBaseline, hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
13
+ import { installBundle, isBundleTemplate } from "./template-bundles.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  const sourceMappings = [
15
16
  ["actions", ".github/actions"],
@@ -286,6 +287,10 @@ const templateCompanions = {
286
287
  };
287
288
  export async function installTemplate(repositoryPath, template, options = {}) {
288
289
  const sourcePath = options.sourcePath ?? catalogSourcePath();
290
+ if (isBundleTemplate(template)) {
291
+ const bundle = await installBundle(repositoryPath, template, sourcePath, { force: options.force });
292
+ return { installed: bundle.installed, conflicts: [] };
293
+ }
289
294
  const meta = catalogTemplateMeta(template);
290
295
  const source = join(sourcePath, "templates", meta.directory, meta.file);
291
296
  const target = meta.target;
@@ -1,5 +1,7 @@
1
1
  import { access, constants } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { catalogSourcePath } from "./catalog-installation.js";
4
+ import { installedBundles } from "./template-bundles.js";
3
5
  import { catalogTemplates, workflowRoutes } from "./workflow-catalog.js";
4
6
  export async function listCatalog(options = {}) {
5
7
  const basePath = options.installedPath ?? process.cwd();
@@ -10,12 +12,15 @@ export async function listCatalog(options = {}) {
10
12
  file: route.worker,
11
13
  installed: await isFileInstalled(basePath, route.worker),
12
14
  })));
15
+ // A bundle counts as installed by its ownership headers, not by its paths: a repository may keep
16
+ // its own headerless files at the same places.
17
+ const bundles = await installedBundles(basePath, catalogSourcePath());
13
18
  const templates = await Promise.all(catalogTemplates.map(async (template) => ({
14
19
  kind: "template",
15
20
  name: template.name,
16
21
  description: template.description,
17
22
  file: template.file,
18
- installed: await isFileInstalled(basePath, template.file, template.target),
23
+ installed: template.bundle === true ? bundles.includes(template.name) : await isFileInstalled(basePath, template.file, template.target),
19
24
  })));
20
25
  return [...routes, ...templates];
21
26
  }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
3
- import { installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
3
+ import { catalogSourcePath, installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
4
+ import { installBundle, installedBundles, isBundleTemplate } from "./template-bundles.js";
4
5
  import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
5
6
  import { packageVersion } from "./package-baseline.js";
6
7
  import { routeNames, templateNames } from "./workflow-catalog.js";
@@ -48,7 +49,9 @@ What an update does to a package-managed file:
48
49
  follows the package when its default changes. The agent runner pool and the engine gateway URL
49
50
  are kept as well. Everything else in the file is the package's.
50
51
  A file whose ownership header was removed is yours and is left alone unless --force is passed.
51
- Templates are yours after installation and are replaced only with --force.
52
+ Single-file templates are yours after installation and are replaced only with --force.
53
+ Bundle templates (quality-gates, platform-template) stay package-managed like the catalog:
54
+ add --template installs one, and update refreshes it only where its files carry the header.
52
55
 
53
56
  Options:
54
57
  --dry-run Print what add or update would change, write nothing.
@@ -118,15 +121,25 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
118
121
  const allInstalled = [];
119
122
  let catalog;
120
123
  const templatesPlanned = [];
124
+ const bundles = [];
125
+ const sourcePath = catalogSourcePath();
121
126
  // The installed set is always part of the target set: adding a route never drops another,
122
- // and a plain update refreshes exactly what is there.
127
+ // and a plain update refreshes exactly what is there, bundle templates included. A bundle
128
+ // nobody installed is never added by an update.
123
129
  if (routes.length > 0 || template === undefined) {
124
130
  const selectedRoutes = unionRoutes(routes, await installedRoutes(repositoryPath));
125
131
  catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
126
132
  allConflicts.push(...catalog.conflicts);
127
133
  allInstalled.push(...catalog.installed);
134
+ for (const name of await installedBundles(repositoryPath, sourcePath)) {
135
+ if (name !== template)
136
+ bundles.push(await installBundle(repositoryPath, name, sourcePath, { force, dryRun }));
137
+ }
138
+ }
139
+ if (template !== undefined && isBundleTemplate(template)) {
140
+ bundles.push(await installBundle(repositoryPath, template, sourcePath, { force, dryRun }));
128
141
  }
129
- if (template !== undefined) {
142
+ else if (template !== undefined) {
130
143
  if (dryRun) {
131
144
  templatesPlanned.push(template);
132
145
  }
@@ -142,11 +155,14 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
142
155
  }
143
156
  for (const warning of catalog?.warnings ?? [])
144
157
  console.error(`warning: ${warning}`);
158
+ for (const bundle of bundles)
159
+ allInstalled.push(...bundle.installed);
145
160
  console.log(JSON.stringify({
146
161
  command,
147
162
  dryRun,
148
163
  ...(catalog === undefined ? {} : summarize(catalog)),
149
164
  ...(templatesPlanned.length > 0 ? { templatesPlanned } : {}),
165
+ ...(bundles.length > 0 ? { templates: bundles.map(summarizeBundle) } : {}),
150
166
  installed: allInstalled.sort(),
151
167
  conflicts: allConflicts,
152
168
  }, null, 2));
@@ -183,6 +199,13 @@ function summarize(result) {
183
199
  ...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
184
200
  };
185
201
  }
202
+ function summarizeBundle(result) {
203
+ return {
204
+ template: result.template,
205
+ changes: result.changes.filter((change) => change.status !== "unchanged"),
206
+ unchanged: result.changes.filter((change) => change.status === "unchanged").length,
207
+ };
208
+ }
186
209
  // A fixed baseline for every recorded version, instead of asking npm. Rolling out a version that
187
210
  // is not published yet leaves the merge two-way, which keeps every consumer value, including the
188
211
  // package defaults a consumer never chose and would rather have replaced.
@@ -0,0 +1,21 @@
1
+ import type { FileChange } from "./catalog-installation.js";
2
+ import { type TemplateName } from "./workflow-catalog.js";
3
+ export interface BundleInstallOptions {
4
+ /** Also overwrite files whose ownership header was removed. */
5
+ readonly force?: boolean;
6
+ /** Compute the plan and write nothing. */
7
+ readonly dryRun?: boolean;
8
+ readonly sourcePath?: string;
9
+ /** The version stamped into headers. Defaults to this package's. */
10
+ readonly packageVersion?: string;
11
+ }
12
+ export interface BundleInstallResult {
13
+ readonly template: TemplateName;
14
+ /** Every target of the bundle, whether it changed or not. */
15
+ readonly installed: readonly string[];
16
+ readonly changes: readonly FileChange[];
17
+ }
18
+ export declare function isBundleTemplate(template: TemplateName): boolean;
19
+ /** The bundles whose files carry our ownership header in this repository. */
20
+ export declare function installedBundles(repositoryPath: string, sourcePath: string): Promise<TemplateName[]>;
21
+ export declare function installBundle(repositoryPath: string, template: TemplateName, sourcePath: string, options?: BundleInstallOptions): Promise<BundleInstallResult>;
@@ -0,0 +1,108 @@
1
+ // A bundle template stays package-managed after installation, under the catalog's header rules. It
2
+ // counts as installed only where one of its files carries our header, so `update` never reaches a
3
+ // repository that keeps its own headerless copies of the same paths.
4
+ import { access, chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
5
+ import { constants } from "node:fs";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
8
+ import { bundleTemplateNames, catalogTemplates } from "./workflow-catalog.js";
9
+ const CONSUMER_OWNED = "consumer-owned: the ownership header was removed; pass --force to reclaim it";
10
+ const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs|tsv)$/.test(target);
11
+ const normalizeEol = (text) => text.replaceAll("\r\n", "\n");
12
+ export function isBundleTemplate(template) {
13
+ return bundleTemplateNames.includes(template);
14
+ }
15
+ /** The bundles whose files carry our ownership header in this repository. */
16
+ export async function installedBundles(repositoryPath, sourcePath) {
17
+ const installed = [];
18
+ for (const template of bundleTemplateNames) {
19
+ for (const target of await bundleTargets(sourcePath, template)) {
20
+ if (!carriesHeader(target))
21
+ continue;
22
+ const current = await readOptional(join(repositoryPath, target));
23
+ if (current !== undefined && hasOwnershipHeader(current)) {
24
+ installed.push(template);
25
+ break;
26
+ }
27
+ }
28
+ }
29
+ return installed;
30
+ }
31
+ export async function installBundle(repositoryPath, template, sourcePath, options = {}) {
32
+ const version = options.packageVersion ?? await packageVersion();
33
+ const root = bundleRoot(sourcePath, template);
34
+ const targets = await bundleTargets(sourcePath, template);
35
+ const changes = [];
36
+ const writes = [];
37
+ for (const target of targets) {
38
+ const current = await readOptional(join(repositoryPath, target));
39
+ const text = stampVersion(normalizeEol(await readFile(join(root, target), "utf8")), version);
40
+ const recorded = current === undefined ? undefined : installedVersion(current);
41
+ const base = recorded === undefined ? { target } : { target, installedVersion: recorded };
42
+ if (current === text) {
43
+ changes.push({ ...base, status: "unchanged" });
44
+ }
45
+ else if (current !== undefined && !options.force && await consumerOwned(repositoryPath, target, current, targets)) {
46
+ changes.push({ target, status: "skipped", reason: CONSUMER_OWNED });
47
+ }
48
+ else if (current === undefined && documentsSkippedJson(target, changes)) {
49
+ // The JSON this note documents stays the consumer's, so the note would claim a file we do not own.
50
+ changes.push({ target, status: "skipped", reason: `documents the consumer-owned ${target.slice(0, -".md".length)}` });
51
+ }
52
+ else {
53
+ changes.push({ ...base, status: current === undefined ? "added" : "updated" });
54
+ writes.push({ target, content: text });
55
+ }
56
+ }
57
+ if (!options.dryRun) {
58
+ for (const { target, content } of writes) {
59
+ const destination = join(repositoryPath, target);
60
+ await mkdir(dirname(destination), { recursive: true });
61
+ await writeFile(destination, content, "utf8");
62
+ // A script a workflow runs by its path needs the executable bit, which a plain write never sets.
63
+ if (content.startsWith("#!"))
64
+ await chmod(destination, 0o755);
65
+ }
66
+ }
67
+ return { template, installed: targets, changes };
68
+ }
69
+ // A JSON file's header lives in its companion `<file>.md`; without that note the JSON is the consumer's.
70
+ async function consumerOwned(repositoryPath, target, current, targets) {
71
+ if (carriesHeader(target))
72
+ return !hasOwnershipHeader(current);
73
+ const companion = `${target}.md`;
74
+ if (!targets.includes(companion))
75
+ return false;
76
+ const note = await readOptional(join(repositoryPath, companion));
77
+ return note === undefined || !hasOwnershipHeader(note);
78
+ }
79
+ // Targets are sorted, so a JSON file is always decided before the `<file>.md` that documents it.
80
+ function documentsSkippedJson(target, changes) {
81
+ if (!target.endsWith(".json.md"))
82
+ return false;
83
+ const json = target.slice(0, -".md".length);
84
+ return changes.some((change) => change.target === json && change.status === "skipped");
85
+ }
86
+ function bundleRoot(sourcePath, template) {
87
+ const entry = catalogTemplates.find((item) => item.name === template);
88
+ if (entry?.bundle !== true || entry.directory === undefined)
89
+ throw new Error(`${template} is not a bundle template`);
90
+ return join(sourcePath, "templates", entry.directory);
91
+ }
92
+ async function bundleTargets(sourcePath, template) {
93
+ const root = bundleRoot(sourcePath, template);
94
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
95
+ return entries
96
+ .filter((entry) => entry.isFile())
97
+ .map((entry) => relative(root, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
98
+ .sort();
99
+ }
100
+ async function readOptional(path) {
101
+ try {
102
+ await access(path, constants.F_OK);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ return normalizeEol(await readFile(path, "utf8"));
108
+ }
@@ -19,7 +19,7 @@ export interface MandatoryFile {
19
19
  }
20
20
  export declare const mandatoryFiles: readonly MandatoryFile[];
21
21
  export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
22
- export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json"];
22
+ export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
23
23
  export type TemplateName = (typeof templateNames)[number];
24
24
  export interface CatalogTemplate {
25
25
  readonly name: TemplateName;
@@ -28,5 +28,9 @@ export interface CatalogTemplate {
28
28
  readonly target?: string;
29
29
  /** Subdirectory of `templates/` holding the file. Defaults to "agentics". */
30
30
  readonly directory?: string;
31
+ /** Several files under `templates/<directory>/`, package-managed and refreshed by `update` where installed. */
32
+ readonly bundle?: boolean;
31
33
  }
32
34
  export declare const catalogTemplates: readonly CatalogTemplate[];
35
+ /** Bundle templates: several package-managed files, refreshed by `update` where installed. */
36
+ export declare const bundleTemplateNames: readonly TemplateName[];
@@ -60,6 +60,8 @@ export const templateNames = [
60
60
  "feature-request",
61
61
  "github-release",
62
62
  "opencode.ci.json",
63
+ "platform-template",
64
+ "quality-gates",
63
65
  ];
64
66
  export const catalogTemplates = [
65
67
  { name: "agentics-app-errors", file: "agentics-app-errors.yml", description: "Daily application error report: asks Application Insights what the deployed app threw, groups it, and files one issue per distinct problem HERE, labelled bug + refine so the belt picks it up. Deterministic, no model; the report never leaves this repository, and a leak scanner withholds anything carrying an identifier." },
@@ -72,4 +74,10 @@ export const catalogTemplates = [
72
74
  { name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
73
75
  { name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
74
76
  { name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
77
+ { name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Platform family template sync: a weekly workflow that runs copier update against the Kitchen-Sink template and opens a pull request per new tag, and a CI guard that keeps .platform/manifest paths unchanged outside a sync. Does nothing without .copier-answers.yml." },
78
+ { name: "quality-gates", file: "scripts/check-coverage.mjs", directory: "quality-gates", bundle: true, description: "Platform family quality gates: coverage, complexity, duplication and mutation gates, the Engineering Excellence facets, the Semgrep hygiene rules, jscpd and ZAP triage. Floors come from the app's quality.config.json; baselines stay app files." },
75
79
  ];
80
+ /** Bundle templates: several package-managed files, refreshed by `update` where installed. */
81
+ export const bundleTemplateNames = catalogTemplates
82
+ .filter((template) => template.bundle === true)
83
+ .map((template) => template.name);