@caddis/cli 0.4.23 → 0.4.25
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/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +174 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/_template/profile.json +55 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/batch-job/profile.json +68 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/dotnet-service/profile.json +67 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/java-service/profile.json +62 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/node-service/profile.json +66 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/php-db/profile.json +75 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/python-web/profile.json +73 -0
- package/bundles/antigravity-plugin/skills/adopt-app/profiles/static-site/profile.json +87 -0
- package/bundles/antigravity-plugin/skills/adopt-app/references/config.example.json +18 -0
- package/bundles/antigravity-plugin/skills/adopt-app/references/profile-contract.md +207 -0
- package/bundles/antigravity-plugin/skills/adopt-app/references/standard.md +304 -0
- package/bundles/antigravity-plugin/skills/adopt-app/scripts/AdoptCommon.ps1 +812 -0
- package/bundles/antigravity-plugin/skills/adopt-app/scripts/Get-AppInventory.ps1 +236 -0
- package/bundles/antigravity-plugin/skills/adopt-app/scripts/New-AdoptionPlan.ps1 +311 -0
- package/bundles/antigravity-plugin/skills/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
- package/bundles/antigravity-plugin/skills/adopt-app/scripts/Test-AppConformance.ps1 +343 -0
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +174 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/_template/profile.json +55 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/batch-job/profile.json +68 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/dotnet-service/profile.json +67 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/java-service/profile.json +62 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/node-service/profile.json +66 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/php-db/profile.json +75 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/python-web/profile.json +73 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/profiles/static-site/profile.json +87 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/references/config.example.json +18 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/references/profile-contract.md +207 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/references/standard.md +304 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/scripts/AdoptCommon.ps1 +812 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Get-AppInventory.ps1 +236 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/scripts/New-AdoptionPlan.ps1 +311 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Resolve-AppProfile.ps1 +246 -0
- package/bundles/codex/.codex/skills/devops/adopt-app/scripts/Test-AppConformance.ps1 +343 -0
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "static-site",
|
|
3
|
+
"status": "draft",
|
|
4
|
+
"description": "A static site or a statically-generated site (for example built with a static-site generator, or plain HTML/CSS/JS) with no backend process of its own.",
|
|
5
|
+
"detect": {
|
|
6
|
+
"anyFiles": [
|
|
7
|
+
"index.html",
|
|
8
|
+
"astro.config.mjs",
|
|
9
|
+
"astro.config.ts",
|
|
10
|
+
"gatsby-config.js",
|
|
11
|
+
"next.config.js",
|
|
12
|
+
"hugo.toml",
|
|
13
|
+
"hugo.yaml",
|
|
14
|
+
"_config.yml",
|
|
15
|
+
"mkdocs.yml",
|
|
16
|
+
"docusaurus.config.js",
|
|
17
|
+
"vuepress.config.js"
|
|
18
|
+
],
|
|
19
|
+
"noneFiles": [
|
|
20
|
+
"pyproject.toml",
|
|
21
|
+
"requirements.txt",
|
|
22
|
+
"composer.json",
|
|
23
|
+
"index.php",
|
|
24
|
+
"**/*.csproj",
|
|
25
|
+
"pom.xml",
|
|
26
|
+
"build.gradle",
|
|
27
|
+
"build.gradle.kts"
|
|
28
|
+
],
|
|
29
|
+
"noneContain": [
|
|
30
|
+
{
|
|
31
|
+
"file": "package.json",
|
|
32
|
+
"pattern": "\"(express|fastify|hono|@nestjs/core|koa)\""
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"runtimes": [
|
|
37
|
+
{
|
|
38
|
+
"kind": "node",
|
|
39
|
+
"version": "<from package.json engines, if a generator is used to build the site>",
|
|
40
|
+
"provision": "<needed only at build time, not to serve the finished site>"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"build": [
|
|
44
|
+
{
|
|
45
|
+
"what": "Dependencies, if the site is generated",
|
|
46
|
+
"command": "npm ci"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"what": "Generate the static output",
|
|
50
|
+
"command": "npm run build"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"services": [
|
|
54
|
+
{
|
|
55
|
+
"role": "web",
|
|
56
|
+
"port": "<the site's HTTP port, if served by a small process rather than a CDN or web server>",
|
|
57
|
+
"start": "<a static file server, or the site is served directly by a web server / CDN with no process of its own>",
|
|
58
|
+
"manager": "<a service manager, or none if a web server serves the files directly>",
|
|
59
|
+
"bind": "<loopback-only, if a trusted edge fronts it>",
|
|
60
|
+
"health": "<the home page returns 200 with the expected content>"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"routing": {
|
|
64
|
+
"mode": "<dedicated-site|none>",
|
|
65
|
+
"staticRoot": "<the built output folder, e.g. dist or public>",
|
|
66
|
+
"apiPrefix": "<n/a>"
|
|
67
|
+
},
|
|
68
|
+
"config": {
|
|
69
|
+
"files": ["<build-time environment variables, if any>"],
|
|
70
|
+
"rule": "no secrets belong in a static bundle - anything shipped to the browser is public"
|
|
71
|
+
},
|
|
72
|
+
"data": {
|
|
73
|
+
"migrations": "none",
|
|
74
|
+
"state": "none - the site is a build artefact; content lives in the source repo or a content system, not on the serving host"
|
|
75
|
+
},
|
|
76
|
+
"ci": {
|
|
77
|
+
"workflow": "<CI workflow file: lint, build>",
|
|
78
|
+
"requiredJobs": ["<build>"],
|
|
79
|
+
"deployJob": "<deploy_prod>"
|
|
80
|
+
},
|
|
81
|
+
"deploy": {
|
|
82
|
+
"script": "<deploy script: build, then publish the output folder to where it is served from>",
|
|
83
|
+
"verify": [
|
|
84
|
+
"a rendered page's content or the build output's hash changed - not just an HTTP 200 from the serving host"
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"outDir": ".caddis/adopt",
|
|
3
|
+
"fileBudget": 20000,
|
|
4
|
+
"skipFolders": [],
|
|
5
|
+
"skipPattern": "(?i)[\\\\/](\\.git|node_modules|\\.venv|venv|__pycache__|\\.mypy_cache|\\.pytest_cache|\\.tox|dist|build|target|bin|obj|vendor|packages|\\.terraform|\\.gradle|\\.next|\\.nuxt|coverage)([\\\\/]|$)",
|
|
6
|
+
"expectedRemotePattern": "^https://github\\.com/example-org/",
|
|
7
|
+
"untrackedThresholdMB": 10,
|
|
8
|
+
"_comments": {
|
|
9
|
+
"_read_this_first": "Copy this to .caddis/adopt-app.json in the repo you run the skill from, or point $env:ADOPT_APP_CONFIG at a copy elsewhere, then edit the values. JSON has no comment syntax, so this _comments object exists only to explain the keys above. Get-AdoptConfig merges every top-level key of your file over the defaults; nothing reads a key named _comments.",
|
|
10
|
+
"_every_key_is_live": "Each key below is read by a named script. There are deliberately no others. An earlier version of this file also listed blockingItems and contentPolicy, which no script ever consulted - a project could set them, see no effect and never find out. Config that does nothing is a promise the code does not keep, so those keys were removed from the code and from here together.",
|
|
11
|
+
"outDir": "Where phase output goes, relative to the repo root: <outDir>/<name>/inventory.json and the rest. Read by Resolve-AdoptOutDir, and only when a phase script runs without its own -OutDir.",
|
|
12
|
+
"fileBudget": "How many files the scan will walk per top-level folder before it stops. Read by Get-AppInventory.ps1 when -FileBudget is not passed; the parameter always wins. Reaching the budget does not fail quietly: the folder's size becomes a MINIMUM, the scan is marked partial, and the script exits 1 unless you pass -AllowPartial.",
|
|
13
|
+
"skipFolders": "Top-level folder names never walked at all. Read by Get-AppInventory.ps1 when -SkipFolder is not passed. Use it for a tree you already know is bulk data, so the budget is spent where the answer is still open. A skipped folder is recorded as NOT SCANNED, never as empty, and it stops source-only, runtime-manifest and data-recovery from passing.",
|
|
14
|
+
"skipPattern": "Paths whose CONTENTS are not classified - dependency folders, build output, caches. They are still measured for size and still classified as excludable content, but their files are not read for credentials or indexed for stack detection. Read by Get-AppInventory.ps1 and passed to Invoke-AdoptScan.",
|
|
15
|
+
"expectedRemotePattern": "The regex that means 'the team's host'. Read by Test-AppConformance.ps1 for the remote-on-team-host item: a repository whose remote URL does not match is a GAP even though it has a remote, which is how a repository sitting on someone's personal account gets caught. Leave it empty and any remote passes, because the check will not guess.",
|
|
16
|
+
"untrackedThresholdMB": "A folder that is neither committed nor ignored and is at least this large is a finding. Read by Test-AppConformance.ps1 when -UntrackedThresholdMB is not passed. This is the state that turns one 'git add -A' into gigabytes of runtime data in the history for ever, and no filename pattern finds it - only git can."
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# The profile contract
|
|
2
|
+
|
|
3
|
+
A profile describes one KIND of component, not one app. It lives at
|
|
4
|
+
`profiles/<stack-name>/profile.json`. `profiles/_template/profile.json` is the starting point
|
|
5
|
+
for a new one, and phase 2 (`Resolve-AppProfile.ps1`) never matches `_template` itself against a
|
|
6
|
+
real inventory unless something explicitly asks `Get-AdoptProfiles` for it with
|
|
7
|
+
`-IncludeTemplate`.
|
|
8
|
+
|
|
9
|
+
Every profile file is loaded and checked against this contract BEFORE any detection runs, by
|
|
10
|
+
`Test-AdoptProfileContract` in `AdoptCommon.ps1`. **One broken profile fails detection for every
|
|
11
|
+
app being resolved, not just the one that owns it** - `Resolve-AppProfile.ps1` refuses to run at
|
|
12
|
+
all while any profile under `profiles/` fails the checks below.
|
|
13
|
+
|
|
14
|
+
## Required keys
|
|
15
|
+
|
|
16
|
+
All ten must be present. The contract only checks that the key EXISTS; it does not validate the
|
|
17
|
+
shape of what is inside it.
|
|
18
|
+
|
|
19
|
+
| Key | Meaning |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `name` | The stack's short name, e.g. `node-service`. Matches the folder name by convention. |
|
|
22
|
+
| `status` | `proven`, `draft`, or `placeholder`. See below. |
|
|
23
|
+
| `description` | One sentence: what kind of component this is. |
|
|
24
|
+
| `detect` | The rules that decide whether a component folder is this stack. See below. |
|
|
25
|
+
| `runtimes` | The language/runtime(s) it needs: kind, exact version, and how that version gets onto a box. |
|
|
26
|
+
| `build` | The ordered build steps: what each one does and the command. |
|
|
27
|
+
| `routing` | How traffic reaches it: reverse proxy, a rewrite rule, a dedicated site, or none. |
|
|
28
|
+
| `config` | Which files carry per-environment settings, and the rule that keeps them out of version control. |
|
|
29
|
+
| `data` | The migration tool, if any, and where persistent state actually lives. |
|
|
30
|
+
| `ci` | The CI workflow file, the jobs that must pass, and which job deploys. |
|
|
31
|
+
| `deploy` | The deploy script, and at least one check that would FAIL on a broken deploy. |
|
|
32
|
+
|
|
33
|
+
**Plus one rule that is not a top-level key check:** the profile must also carry `services` or
|
|
34
|
+
`schedule` (or both).
|
|
35
|
+
|
|
36
|
+
- `services` describes a component that answers requests. Each entry names a role
|
|
37
|
+
(`api`/`web`/`worker`/`database`), a port, a start command, the process manager that keeps it
|
|
38
|
+
running, the interface it binds, and a health check.
|
|
39
|
+
- `schedule` describes a batch component instead: what triggers a run - a scheduler, a
|
|
40
|
+
file-arrival watcher, or a manual/CI trigger.
|
|
41
|
+
|
|
42
|
+
A component that is neither long-running nor scheduled does not fit this contract yet.
|
|
43
|
+
|
|
44
|
+
## Status values
|
|
45
|
+
|
|
46
|
+
| Status | Licenses |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `placeholder` | Only `_template`'s own state. Its `name` is still the literal `<stack-name>` placeholder text - which is exactly why the "no leftover placeholder" rule below is skipped for `placeholder` alone. |
|
|
49
|
+
| `draft` | A description a person can follow by hand. It can still be DETECTED and matched to a real component - phase 2 will report it, with its status shown as `draft` - but nothing has automated it end to end yet. Follow it manually, write down what you actually did, and let it earn `proven` later. |
|
|
50
|
+
| `proven` | Something has actually automated this stack end to end: build, deploy and verify have all been run for real against a live component, not only described. |
|
|
51
|
+
|
|
52
|
+
Two more rules `Test-AdoptProfileContract` enforces on `status`:
|
|
53
|
+
|
|
54
|
+
- It must be exactly one of the three values above. Anything else fails the contract for every
|
|
55
|
+
profile in the directory, not only this one.
|
|
56
|
+
- Unless `status` is `placeholder`, `name` must not contain `<` or `>`. This is what stops a copy
|
|
57
|
+
of `_template` being left half-filled-in and silently matching real apps with its placeholder
|
|
58
|
+
name still inside it.
|
|
59
|
+
|
|
60
|
+
## The five `detect` rule kinds
|
|
61
|
+
|
|
62
|
+
Checked by `Test-AdoptDetect`, against two things gathered per component: a list of relative
|
|
63
|
+
file paths, and the text of a handful of small files. Which files make it into either list is
|
|
64
|
+
bounded - see "What `detect` can actually see" below.
|
|
65
|
+
|
|
66
|
+
| Kind | Shape | Meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `allFiles` | list of globs | Every glob must match at least one path, or the profile does not match at all. |
|
|
69
|
+
| `anyFiles` | list of globs | At least one glob must match at least one path, or the profile does not match at all. |
|
|
70
|
+
| `noneFiles` | list of globs | If ANY glob matches, the profile does not match at all. A pure veto - it never adds to the score. |
|
|
71
|
+
| `fileContains` | list of `{ "file": glob, "pattern": regex }` | Every entry's glob must resolve to a file whose text matches `pattern`, or the profile does not match. |
|
|
72
|
+
| `noneContain` | list of `{ "file": glob, "pattern": regex }` | If any entry's glob resolves to a file whose text matches `pattern`, the profile does not match. A pure veto. |
|
|
73
|
+
|
|
74
|
+
All five are optional; omit whichever a stack does not need. A profile that declares none of them
|
|
75
|
+
never matches anything.
|
|
76
|
+
|
|
77
|
+
## Glob-to-regex translation, exactly
|
|
78
|
+
|
|
79
|
+
Every glob - in `allFiles`, `anyFiles`, `noneFiles`, or a `fileContains`/`noneContain` entry's
|
|
80
|
+
`file` - goes through the same conversion, in this order:
|
|
81
|
+
|
|
82
|
+
1. `[regex]::Escape($glob)` - escape every regex-special character literally, including `*`.
|
|
83
|
+
2. Replace an escaped `**` (now `\*\*`) with `.*` - matches across any number of path segments.
|
|
84
|
+
3. Replace a remaining escaped `*` (`\*`) with `[^\\/]*` - matches within ONE path segment only.
|
|
85
|
+
4. Replace `/` with `[\\/]` - so a glob written with a forward slash matches a path recorded
|
|
86
|
+
with either separator.
|
|
87
|
+
5. Anchor the whole thing as `(?i)^...$` - case-insensitive, and the WHOLE relative path must
|
|
88
|
+
match, not a substring of it.
|
|
89
|
+
|
|
90
|
+
So `src/*.ts` matches `src/index.ts` but not `src/api/index.ts` (one segment only), while
|
|
91
|
+
`src/**/*.ts` matches both. A bare filename like `package.json` matches only at the root of the
|
|
92
|
+
component being tested, not at any depth beneath it - write `**/package.json` for "anywhere
|
|
93
|
+
under this component."
|
|
94
|
+
|
|
95
|
+
## How the score is computed
|
|
96
|
+
|
|
97
|
+
`Test-AdoptDetect` returns `0` for no match, or a positive score where a higher number means a
|
|
98
|
+
more specific match:
|
|
99
|
+
|
|
100
|
+
- `allFiles` declared and satisfied -> **+1**. Declared and NOT satisfied -> return `0`
|
|
101
|
+
immediately, regardless of anything else.
|
|
102
|
+
- `anyFiles` declared and satisfied -> **+1**. Declared and NOT satisfied -> return `0`
|
|
103
|
+
immediately.
|
|
104
|
+
- `fileContains` declared and satisfied -> **+1**. Declared and NOT satisfied -> return `0`
|
|
105
|
+
immediately.
|
|
106
|
+
- `noneFiles` and `noneContain` never add to the score. They are vetoes only: if either is
|
|
107
|
+
violated, the rule returns `0` no matter what else matched.
|
|
108
|
+
|
|
109
|
+
The maximum score is `3` (`allFiles` + `anyFiles` + `fileContains` all declared and all
|
|
110
|
+
satisfied). Phase 2 runs every profile against every component and keeps the highest-scoring
|
|
111
|
+
match as the winner. Everything else that scored above `0` is recorded in `alsoMatches`, never
|
|
112
|
+
discarded - a component genuinely matching two stacks is a decision for a person, and picking one
|
|
113
|
+
silently would hide that decision.
|
|
114
|
+
|
|
115
|
+
## What `detect` can actually see
|
|
116
|
+
|
|
117
|
+
A rule can only use evidence the inventory actually indexed. Two different limits apply,
|
|
118
|
+
depending on what kind of file a glob names.
|
|
119
|
+
|
|
120
|
+
- **Manifests, entry points, config files, CI files, docs, and test markers** - anything matching
|
|
121
|
+
one of the fixed name lists `Get-AppInventory.ps1` already recognises (`package.json`,
|
|
122
|
+
`pyproject.toml`, `Dockerfile`, `main.py`, `.env*`, a file under a `workflows` folder,
|
|
123
|
+
`README*`, `pytest.ini`, and the rest) - are indexed at ANY depth in the tree.
|
|
124
|
+
- **Everything else** - an arbitrary filename a `detect` rule names that is not one of those
|
|
125
|
+
fixed names, for example `index.html` as evidence of a front end - only lands in the general
|
|
126
|
+
shallow path index, and that index is depth-limited: paths at depth 2 or shallower from the
|
|
127
|
+
scanned root are always kept; paths at depth 3-4 are kept only while a single cap shared across
|
|
128
|
+
the WHOLE scan (`DetectPathCap`, default 20000 paths) still has room. Past depth 4, or once
|
|
129
|
+
that cap is spent, a `detect` rule naming such a file cannot see it - not because the file is
|
|
130
|
+
absent, but because nothing recorded that it exists.
|
|
131
|
+
|
|
132
|
+
`fileContains`/`noneContain` add a second limit on top of path indexing: the named file must
|
|
133
|
+
actually be fetched, which only happens for files under 256 KB. A rule that reads a large
|
|
134
|
+
generated file will never see its content.
|
|
135
|
+
|
|
136
|
+
**Practical rule of thumb:** name a real manifest or entry-point filename in
|
|
137
|
+
`allFiles`/`anyFiles` whenever the stack has one - that evidence survives at any depth. Reserve
|
|
138
|
+
arbitrary, non-manifest filenames for secondary evidence, and expect them to be missed on a
|
|
139
|
+
deeply nested component or a very large tree.
|
|
140
|
+
|
|
141
|
+
## Worked example: adding a new stack
|
|
142
|
+
|
|
143
|
+
Say a fleet starts running a Rust service and no profile describes it yet. Phase 2 would report
|
|
144
|
+
"NO PROFILE MATCHED ANY FOLDER" and exit 2 for that component.
|
|
145
|
+
|
|
146
|
+
1. Copy the template:
|
|
147
|
+
|
|
148
|
+
cp -r profiles/_template profiles/rust-service
|
|
149
|
+
|
|
150
|
+
2. Fill in `profiles/rust-service/profile.json`. Start with `status: "draft"` - nothing has
|
|
151
|
+
automated this stack yet, only described it:
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
"name": "rust-service",
|
|
155
|
+
"status": "draft",
|
|
156
|
+
"description": "A Rust HTTP service built with Cargo, e.g. Actix Web or Axum.",
|
|
157
|
+
"detect": {
|
|
158
|
+
"allFiles": ["Cargo.toml"],
|
|
159
|
+
"fileContains": [
|
|
160
|
+
{ "file": "Cargo.toml", "pattern": "(actix-web|axum|rocket)" }
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
"runtimes": [
|
|
164
|
+
{
|
|
165
|
+
"kind": "rust",
|
|
166
|
+
"version": "<from Cargo.toml or rust-toolchain.toml>",
|
|
167
|
+
"provision": "<installed on dev and prod hosts; confirm the installed version matches what is declared>"
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
"build": [
|
|
171
|
+
{ "what": "Compile", "command": "cargo build --release" }
|
|
172
|
+
],
|
|
173
|
+
"services": [
|
|
174
|
+
{
|
|
175
|
+
"role": "api",
|
|
176
|
+
"port": "<the app's HTTP port>",
|
|
177
|
+
"start": "<the built binary, e.g. target/release/app>",
|
|
178
|
+
"manager": "<a service manager or a container runtime>",
|
|
179
|
+
"bind": "<loopback-only, unless a trusted edge forwards identity>",
|
|
180
|
+
"health": "<the app's real health route - confirm it, do not assume a default path>"
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
"routing": { "mode": "reverse-proxy", "apiPrefix": "/api" },
|
|
184
|
+
"config": {
|
|
185
|
+
"files": ["<the env file it reads, per environment>"],
|
|
186
|
+
"rule": "kept out of version control; a committed example file with placeholder values"
|
|
187
|
+
},
|
|
188
|
+
"data": {
|
|
189
|
+
"migrations": "<its migration tool, if any>",
|
|
190
|
+
"state": "<where persistent state actually lives>"
|
|
191
|
+
},
|
|
192
|
+
"ci": {
|
|
193
|
+
"workflow": "<CI workflow file>",
|
|
194
|
+
"requiredJobs": ["<build_and_test>"],
|
|
195
|
+
"deployJob": "<deploy_prod>"
|
|
196
|
+
},
|
|
197
|
+
"deploy": {
|
|
198
|
+
"script": "<deploy script>",
|
|
199
|
+
"verify": ["<a check that would FAIL on a broken deploy>"]
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
3. Re-run phase 2. If a real component's `Cargo.toml` mentions `axum`, it now scores `2`
|
|
204
|
+
(`allFiles` + `fileContains`) and is reported with `status: draft`.
|
|
205
|
+
4. Follow the profile by hand on a real adoption. Write down what was actually done for build,
|
|
206
|
+
deploy and health, then raise `status` to `proven` only once all of it has genuinely been
|
|
207
|
+
automated - not before.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# The adoption standard
|
|
2
|
+
|
|
3
|
+
`Test-AppConformance.ps1` (phase 3) measures an app against 20 items, in the order below - the
|
|
4
|
+
same order the script adds them in. Each one gets a verdict, never a guess:
|
|
5
|
+
|
|
6
|
+
| Verdict | Means |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `PASS` | Observed true, from the inventory or the profile. |
|
|
9
|
+
| `GAP` | Observed false. The evidence is named. |
|
|
10
|
+
| `ASK` | Not observable from files. A person must answer it. |
|
|
11
|
+
| `ANSWERED` | Was `ASK`; a non-empty answer has been recorded for it (see below). Not a fourth independent state - it only exists once an `ASK` has been answered. |
|
|
12
|
+
|
|
13
|
+
Items marked **blocking** below stop the adoption exactly the same way whether they are `GAP` or
|
|
14
|
+
an unanswered `ASK` - a checklist that only tracks blocking failures and lists open questions as
|
|
15
|
+
notes at the bottom lets those questions be skipped, which is the failure this standard exists to
|
|
16
|
+
prevent.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
### 1. `git-repo` - The source is in a git repository
|
|
21
|
+
|
|
22
|
+
**Blocking.**
|
|
23
|
+
|
|
24
|
+
**Decided by:** `PASS` if the source folder contains a `.git` directory (`inventory.git.isRepo`
|
|
25
|
+
is true). `GAP` otherwise, with "no `.git` in the source folder" as the evidence.
|
|
26
|
+
|
|
27
|
+
**Why it is on the list:** an app with no repository has no history and no way to see what
|
|
28
|
+
changed. Adoption starts by importing the folder as one commit - but only once the content
|
|
29
|
+
policy (item 4) is settled, because that first commit decides what is in the history forever.
|
|
30
|
+
|
|
31
|
+
### 2. `remote-on-team-host` - The repository lives on the team's host
|
|
32
|
+
|
|
33
|
+
**Blocking.**
|
|
34
|
+
|
|
35
|
+
**Decided by:** `GAP` if there is no repository at all; `GAP` if a repository exists but has zero
|
|
36
|
+
remotes configured ("this folder is the ONLY copy of the history"); `GAP` if
|
|
37
|
+
`expectedRemotePattern` is set in the config and none of the remote URLs match it; `PASS` if a
|
|
38
|
+
remote exists and either no pattern is configured or one remote matches it.
|
|
39
|
+
|
|
40
|
+
**Why it is on the list:** a repository with no remote, or hosted on a personal account, is one
|
|
41
|
+
disk failure or one departure away from disappearing. `expectedRemotePattern` is what lets this
|
|
42
|
+
check tell "the team's host" from "somewhere else" - without it configured, having any remote at
|
|
43
|
+
all is enough to pass, which is a weaker check than it looks like.
|
|
44
|
+
|
|
45
|
+
### 3. `canonical-branch` - Which branch is the truth is written down
|
|
46
|
+
|
|
47
|
+
**Blocking.**
|
|
48
|
+
|
|
49
|
+
**Decided by:** `ASK` when the app has a repository AND (it has no remote, OR it has more than 3
|
|
50
|
+
local branches). `PASS` otherwise. Note the edge case this produces: when there is no repository
|
|
51
|
+
at all, this item reads `PASS` (evidence: "no repository") even though `git-repo` itself is a
|
|
52
|
+
`GAP` for the same app - there being no branches yet is not treated as a question to ask.
|
|
53
|
+
|
|
54
|
+
**Why it is on the list:** with more than a handful of local branches and no shared remote to
|
|
55
|
+
compare against, which one is actually deployed cannot be read from the files. Wiring CI to the
|
|
56
|
+
wrong branch before this is answered means CI builds and tests code nobody ships.
|
|
57
|
+
|
|
58
|
+
### 4. `source-only` - The repository holds source, not runtimes, data or dependencies
|
|
59
|
+
|
|
60
|
+
**Blocking.**
|
|
61
|
+
|
|
62
|
+
**Decided by:** `GAP` if either of these is true: a top-level folder that IS git-tracked also
|
|
63
|
+
matches an excludable content category (dependency, runtime, build-output, cache, db-data,
|
|
64
|
+
archive, data-export, or media - `log` is exempted here) - i.e. something was committed that
|
|
65
|
+
should not have been; or a top-level folder is git-UNTRACKED, is not `.git`, and is at least
|
|
66
|
+
`-UntrackedThresholdMB` (default 10 MB). Otherwise `PASS` - except a `PASS` is downgraded to
|
|
67
|
+
`ASK` if any top-level folder was scanned with `-SkipFolder` (see "Coverage" below).
|
|
68
|
+
|
|
69
|
+
**Why it is on the list:** a `.gitignore` added later does not remove what is already committed -
|
|
70
|
+
that needs a history rewrite. And a folder that is neither committed nor ignored is the genuinely
|
|
71
|
+
dangerous state: it sits there doing nothing until one `git add -A` puts it in the history
|
|
72
|
+
forever. A filename pattern cannot find that state; only asking git can.
|
|
73
|
+
|
|
74
|
+
### 5. `no-secrets` - No credential is in the working tree
|
|
75
|
+
|
|
76
|
+
**Blocking.**
|
|
77
|
+
|
|
78
|
+
**Decided by:** `GAP` if the inventory's `secretsSuspected` list is non-empty (files flagged by
|
|
79
|
+
name, e.g. `.env`, `*.pem`, `id_rsa`, `credentials.json`; or by content, e.g. a line shaped like
|
|
80
|
+
`password = "..."` or a live-looking token, with comments, non-literal values, and template
|
|
81
|
+
references excluded). `PASS` if the heuristics found nothing - which the evidence text says
|
|
82
|
+
explicitly is not proof there is nothing.
|
|
83
|
+
|
|
84
|
+
**Why it is on the list:** history keeps a credential, so the fix is to ROTATE it, not delete it.
|
|
85
|
+
Deleting the file in a later commit does not stop anyone who already has a clone, or ever will,
|
|
86
|
+
from reading the old value out of history. Only rotating the credential itself closes that.
|
|
87
|
+
|
|
88
|
+
### 6. `config-separation` - Configuration is outside the code, per environment
|
|
89
|
+
|
|
90
|
+
**Not blocking.**
|
|
91
|
+
|
|
92
|
+
**Decided by:** `PASS` if at least one config file looks like a committed template (matches a
|
|
93
|
+
name like `.env.example`, `*.sample`, `*.template`, or `*.dist`). `ASK` if the inventory found
|
|
94
|
+
zero config files at all. `GAP` if config files exist but none of them look like a template.
|
|
95
|
+
|
|
96
|
+
**Why it is on the list:** hardcoded, per-environment values in committed config are exactly
|
|
97
|
+
where a real credential tends to leak. A committed template proves the separation exists without
|
|
98
|
+
exposing a real value; its absence does not prove the opposite, which is why zero config files
|
|
99
|
+
reads as a question rather than a pass or a fail.
|
|
100
|
+
|
|
101
|
+
### 7. `deps-pinned` - Dependencies are pinned
|
|
102
|
+
|
|
103
|
+
**Not blocking.**
|
|
104
|
+
|
|
105
|
+
**Decided by:** `PASS` if a recognised lock file is present among the manifests
|
|
106
|
+
(`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `poetry.lock`, `Pipfile.lock`,
|
|
107
|
+
`composer.lock`, `Gemfile.lock`, `Cargo.lock`, `go.sum`). `GAP` otherwise.
|
|
108
|
+
|
|
109
|
+
**Why it is on the list:** without a lock file the build is not reproducible, and a broken deploy
|
|
110
|
+
cannot be told apart from a dependency that moved underneath it between two runs.
|
|
111
|
+
|
|
112
|
+
### 8. `tests` - There is an automated test suite
|
|
113
|
+
|
|
114
|
+
**Not blocking.**
|
|
115
|
+
|
|
116
|
+
**Decided by:** `PASS` if the inventory's test-file count is greater than zero (files under a
|
|
117
|
+
`tests`/`spec`/`__tests__` folder, or matching a test-name convention like `*_test.py` or
|
|
118
|
+
`*.spec.ts`). `GAP` otherwise.
|
|
119
|
+
|
|
120
|
+
**Why it is on the list:** CI that runs no tests only proves the code compiles.
|
|
121
|
+
|
|
122
|
+
### 9. `ci-build` - CI builds and tests on every push
|
|
123
|
+
|
|
124
|
+
**Not blocking.**
|
|
125
|
+
|
|
126
|
+
**Decided by:** `PASS` if any CI file was found (a workflow under `.github`/`.gitea`/`.forgejo`,
|
|
127
|
+
or a known pipeline file like `Jenkinsfile`, `azure-pipelines.yml`, `.travis.yml`,
|
|
128
|
+
`appveyor.yml`, `bitbucket-pipelines.yml`). `GAP` if none was found.
|
|
129
|
+
|
|
130
|
+
**Why it is on the list:** without CI, "it works" means "it worked on one machine, once."
|
|
131
|
+
|
|
132
|
+
### 10. `ci-deploy` - Deployment is reproducible, not manual
|
|
133
|
+
|
|
134
|
+
**Not blocking.**
|
|
135
|
+
|
|
136
|
+
**Decided by:** `ASK` if CI files exist (the file LIST cannot say whether that CI actually
|
|
137
|
+
deploys, only that some pipeline exists). `GAP` if there is no CI at all.
|
|
138
|
+
|
|
139
|
+
**Why it is on the list:** a build pipeline is not the same claim as a deploy pipeline. Whether
|
|
140
|
+
pushing to the default branch actually ships the app has to be answered by a person or by reading
|
|
141
|
+
the workflow's job list, not assumed from its presence.
|
|
142
|
+
|
|
143
|
+
### 11. `runtime-manifest` - Anything excluded from the repository can be got back
|
|
144
|
+
|
|
145
|
+
**Not blocking.**
|
|
146
|
+
|
|
147
|
+
**Decided by:** `PASS` if no ignored folder is 100 MB or larger among the folders that were
|
|
148
|
+
scanned. `ASK` if one or more ignored folders reach that size. A `PASS` is downgraded to `ASK` if
|
|
149
|
+
any top-level folder was scanned with `-SkipFolder` (see "Coverage" below).
|
|
150
|
+
|
|
151
|
+
**Why it is on the list:** excluding a large runtime or dependency tree from the repository is
|
|
152
|
+
correct. It is only SAFE once something says how to get it back - the exact version and where it
|
|
153
|
+
installs from - and a folder nobody looked at cannot be assumed to already have that answer.
|
|
154
|
+
|
|
155
|
+
### 12. `health` - There is a health signal that fails loudly
|
|
156
|
+
|
|
157
|
+
**Not blocking.**
|
|
158
|
+
|
|
159
|
+
**Decided by:** `PASS` if any probed text file contains a line matching `/health`, `/healthz`,
|
|
160
|
+
`/api/health`, or `healthcheck` (case-insensitive). `ASK` if every matched component's profile
|
|
161
|
+
name contains `batch` (a batch job has no HTTP endpoint to check at all). `GAP` otherwise.
|
|
162
|
+
|
|
163
|
+
**Why it is on the list:** a check that returns 200 whatever happens is not a health check.
|
|
164
|
+
Finding the STRING `/health` in the source only proves a route exists - it says nothing about
|
|
165
|
+
whether that route actually fails when the app is broken. That is why the plan step this item
|
|
166
|
+
produces explicitly requires breaking the dependency on purpose and watching the check go red
|
|
167
|
+
before it counts as done.
|
|
168
|
+
|
|
169
|
+
### 13. `run-declared` - How the app runs is declared and version-controlled
|
|
170
|
+
|
|
171
|
+
**Blocking.**
|
|
172
|
+
|
|
173
|
+
**Decided by:** `PASS` if the host's registered services, scheduled tasks, or web sites include
|
|
174
|
+
at least one whose path references this folder (matched on the folder's last path segment,
|
|
175
|
+
against Windows services, scheduled tasks, and IIS sites/applications). `ASK` if nothing on the
|
|
176
|
+
scanned host references it at all.
|
|
177
|
+
|
|
178
|
+
**Why it is on the list:** on a real run, the only "service" `run-declared` found for one app was
|
|
179
|
+
a database engine that happened to be installed inside the app's own folder - not the app's
|
|
180
|
+
process at all. That is why the evidence names each hit individually (`service X`, `task Y`, `site
|
|
181
|
+
Z`): a non-zero count is not proof that any of them is actually running THIS app, and the reader
|
|
182
|
+
has to check, not assume.
|
|
183
|
+
|
|
184
|
+
### 14. `rollback` - There is a way back to the previous version
|
|
185
|
+
|
|
186
|
+
**Not blocking.**
|
|
187
|
+
|
|
188
|
+
**Decided by:** Always `ASK` - the code marks it "not observable from files" unconditionally.
|
|
189
|
+
|
|
190
|
+
**Why it is on the list:** whether the previous release can be restored, by what exact command,
|
|
191
|
+
and whether that command has ever actually been run, is not written down anywhere a scan can
|
|
192
|
+
read. It can only be answered by a person, and then it has to be exercised at least once to be
|
|
193
|
+
believed (see the plan step this item produces).
|
|
194
|
+
|
|
195
|
+
### 15. `owner` - The app has a named owner
|
|
196
|
+
|
|
197
|
+
**Blocking.**
|
|
198
|
+
|
|
199
|
+
**Decided by:** Always `ASK` - "not observable from files" unconditionally.
|
|
200
|
+
|
|
201
|
+
**Why it is on the list:** nothing in a repository can say who is accountable for it. Without a
|
|
202
|
+
named owner there is nobody to decide a change and nobody to call when it breaks, so this has to
|
|
203
|
+
reach a person before anything else proceeds.
|
|
204
|
+
|
|
205
|
+
### 16. `data-recovery` - State can be restored from a backup
|
|
206
|
+
|
|
207
|
+
**Not blocking.**
|
|
208
|
+
|
|
209
|
+
**Decided by:** `PASS` if no ignored folder is 100 MB or larger AND the excludable-content
|
|
210
|
+
summary contains no `db-data` category entry. `ASK` otherwise. A `PASS` is downgraded the same
|
|
211
|
+
way as items 4 and 11 if any top-level folder was scanned with `-SkipFolder`.
|
|
212
|
+
|
|
213
|
+
**Why it is on the list:** excluding a database file or a data dump from the repository is
|
|
214
|
+
correct. It is only safe once a backup schedule exists and a restore from it has actually been
|
|
215
|
+
tested - a repository holding schema and migrations is not itself a backup of the data.
|
|
216
|
+
|
|
217
|
+
### 17. `docs-readme` - A README says what it is and how to run it
|
|
218
|
+
|
|
219
|
+
**Not blocking.**
|
|
220
|
+
|
|
221
|
+
**Decided by:** `PASS` if any doc file's name matches `README*` (case-insensitive). `GAP`
|
|
222
|
+
otherwise.
|
|
223
|
+
|
|
224
|
+
**Why it is on the list:** the first two questions a new person asks - what is this, and how do I
|
|
225
|
+
run it - should not require finding and interrupting someone.
|
|
226
|
+
|
|
227
|
+
### 18. `agent-rules` - A rules file the coding agents read
|
|
228
|
+
|
|
229
|
+
**Not blocking.**
|
|
230
|
+
|
|
231
|
+
**Decided by:** `PASS` if a doc file is named exactly `AGENTS.md` or `CLAUDE.md`
|
|
232
|
+
(case-insensitive). `GAP` otherwise.
|
|
233
|
+
|
|
234
|
+
**Why it is on the list:** without it, every coding agent that touches the app re-derives its
|
|
235
|
+
non-obvious rules from scratch, and different agents derive them differently.
|
|
236
|
+
|
|
237
|
+
### 19. `no-junctions` - No junction or symlink inside the tree git manages
|
|
238
|
+
|
|
239
|
+
**Not blocking.**
|
|
240
|
+
|
|
241
|
+
**Decided by:** `PASS` if no reparse point (junction or symlink) was found outside a dependency
|
|
242
|
+
folder (`node_modules`, `.venv`, `venv`, `vendor`). `GAP` otherwise, naming up to three.
|
|
243
|
+
|
|
244
|
+
**Why it is on the list:** git's own recursive remove (for example `git worktree remove --force`)
|
|
245
|
+
FOLLOWS a junction and deletes the TARGET's contents - this was proven directly, not assumed.
|
|
246
|
+
Ordinary Windows delete operations (`Remove-Item -Recurse`, `cmd /c rmdir /s /q`,
|
|
247
|
+
`[IO.Directory]::Delete`) all correctly refuse to follow a junction, which is exactly what makes
|
|
248
|
+
this trap easy to miss: a delete you tested yourself can look completely safe right up until git
|
|
249
|
+
does the deleting instead.
|
|
250
|
+
|
|
251
|
+
### 20. `profile` - The stack is declared, not assumed
|
|
252
|
+
|
|
253
|
+
**Blocking.**
|
|
254
|
+
|
|
255
|
+
**Decided by:** `PASS` if phase 2 matched at least one component to a profile. `GAP` if it
|
|
256
|
+
matched none.
|
|
257
|
+
|
|
258
|
+
**Why it is on the list:** without a declared stack, every later step - runtimes, build, deploy -
|
|
259
|
+
is a guess dressed up as a plan. "No profile matched" means the rules do not describe this app
|
|
260
|
+
yet; it does not mean the app has no stack.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Coverage: a skipped folder cannot pass a check
|
|
265
|
+
|
|
266
|
+
Three items above (`source-only`, `runtime-manifest`, `data-recovery`) can only reach `PASS`
|
|
267
|
+
through `Limit-ByCoverage`, which downgrades a would-be `PASS` to `ASK` whenever any top-level
|
|
268
|
+
folder was scanned with `-SkipFolder`. A folder passed to `-SkipFolder` is recorded as **not
|
|
269
|
+
scanned**, never as empty and never as clean - it is unknown. On a real run, `-SkipFolder tools`
|
|
270
|
+
hid a 16 GB runtime tree, and without this rule the check for "anything excluded can be got back"
|
|
271
|
+
would have reported `PASS` on a folder nobody had actually looked at. Coverage-dependent checks
|
|
272
|
+
stay open until every folder has been scanned, or the operator explicitly accepts a partial
|
|
273
|
+
inventory (`-AllowPartial` in phase 1).
|
|
274
|
+
|
|
275
|
+
## Answers and waivers
|
|
276
|
+
|
|
277
|
+
**`-Answer id=text`** (phase 3) records a non-empty answer for an item that is currently `ASK`.
|
|
278
|
+
The tool does not judge the content - any non-empty text satisfies it - and the verdict becomes
|
|
279
|
+
`ANSWERED`. Answers persist in `answers.json` inside the app's output folder and are merged with
|
|
280
|
+
any new `-Answer` arguments on every run, so recording one once is enough for every later
|
|
281
|
+
re-check.
|
|
282
|
+
|
|
283
|
+
**`-Waive <id>`** records an accepted exception for an item that is not already `PASS`. It sets a
|
|
284
|
+
`waived` flag; **it never rewrites the verdict itself**. A waived `GAP` is still recorded as
|
|
285
|
+
`GAP`, permanently, with `waived: true` next to it - six months later, that difference between "we
|
|
286
|
+
fixed it" and "we accepted the risk" is the whole value of the record. A waived item is excluded
|
|
287
|
+
from the blocking counts below, so it can no longer stop the exit code, but the report still shows
|
|
288
|
+
what it really is.
|
|
289
|
+
|
|
290
|
+
## Exit codes
|
|
291
|
+
|
|
292
|
+
| Code | Means |
|
|
293
|
+
|---|---|
|
|
294
|
+
| `0` | Every blocking item is `PASS`, `ANSWERED`, or waived. |
|
|
295
|
+
| `1` | At least one blocking item is `GAP` and not waived. Checked first - if both a blocking `GAP` and a blocking unanswered `ASK` exist at once, exit code `1` wins. |
|
|
296
|
+
| `2` | No blocking `GAP` remains, but at least one blocking item is still an unanswered `ASK` and not waived. |
|
|
297
|
+
|
|
298
|
+
The eight blocking items, as the code actually enforces them, are: `git-repo`,
|
|
299
|
+
`remote-on-team-host`, `canonical-branch`, `source-only`, `no-secrets`, `run-declared`, `owner`,
|
|
300
|
+
and `profile`. Which items block is decided in the script, by the `Blocking` argument to each
|
|
301
|
+
item, and not by the project config. An earlier draft of the config carried a `blockingItems`
|
|
302
|
+
list that nothing read and that had drifted out of step with this set; it was removed from the
|
|
303
|
+
code and from `config.example.json` together, rather than documented as a thing that looks
|
|
304
|
+
settable and is not.
|